diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a6f5b86..2f9e90a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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: @@ -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 \ No newline at end of file + run: npm publish --access public --provenance diff --git a/.gitignore b/.gitignore index 6179131..ff8c92b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build output dist/ +/bin/ # Logs *.log diff --git a/README.md b/README.md index 468b9b5..9e4fb53 100644 --- a/README.md +++ b/README.md @@ -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.* @@ -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// +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. @@ -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). diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..9cf0099 --- /dev/null +++ b/buf.gen.yaml @@ -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 diff --git a/buf.lock b/buf.lock new file mode 100644 index 0000000..d15a117 --- /dev/null +++ b/buf.lock @@ -0,0 +1,6 @@ +# Generated by buf. DO NOT EDIT. +version: v2 +deps: + - name: buf.build/bufbuild/protovalidate + commit: 80ab13bee0bf4272b6161a72bf7034e0 + digest: b5:1aa6a965be5d02d64e1d81954fa2e78ef9d1e33a0c30f92bc2626039006a94deb3a5b05f14ed8893f5c3ffce444ac008f7e968188ad225c4c29c813aa5f2daa1 diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..eaa5d2e --- /dev/null +++ b/buf.yaml @@ -0,0 +1,11 @@ +version: v2 +modules: + - path: proto +deps: + - buf.build/bufbuild/protovalidate +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/cmd/opentmux/main.go b/cmd/opentmux/main.go new file mode 100644 index 0000000..7f40c7d --- /dev/null +++ b/cmd/opentmux/main.go @@ -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 +} diff --git a/cmd/opentmuxctl/main.go b/cmd/opentmuxctl/main.go new file mode 100644 index 0000000..b777224 --- /dev/null +++ b/cmd/opentmuxctl/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "time" + + "connectrpc.com/connect" + "github.com/AnganSamadder/opentmux/gen/go/opentmux/v1" + "github.com/AnganSamadder/opentmux/gen/go/opentmux/v1/opentmuxv1connect" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: opentmuxctl [flags]") + os.Exit(2) + } + + socketPath := filepath.Join(os.TempDir(), "opentmuxd.sock") + client := newClient(socketPath) + + switch os.Args[1] { + case "init": + fs := flag.NewFlagSet("init", flag.ExitOnError) + directory := fs.String("directory", "", "project directory") + serverURL := fs.String("server-url", "http://localhost:4096", "opencode server url") + fs.StringVar(&socketPath, "socket", socketPath, "unix socket path") + _ = fs.Parse(os.Args[2:]) + client = newClient(socketPath) + _, err := client.Init(context.Background(), connect.NewRequest(&opentmuxv1.InitRequest{ + Directory: *directory, + ServerUrl: *serverURL, + })) + if err != nil { + exitErr(err) + } + case "session-created": + fs := flag.NewFlagSet("session-created", flag.ExitOnError) + eventType := fs.String("type", "session.created", "event type") + id := fs.String("id", "", "session id") + parentID := fs.String("parent-id", "", "parent session id") + title := fs.String("title", "Subagent", "session title") + fs.StringVar(&socketPath, "socket", socketPath, "unix socket path") + _ = fs.Parse(os.Args[2:]) + client = newClient(socketPath) + _, err := client.OnSessionCreated(context.Background(), connect.NewRequest(&opentmuxv1.SessionCreatedRequest{ + Type: *eventType, + Info: &opentmuxv1.SessionCreatedInfo{Id: *id, ParentId: *parentID, Title: *title}, + })) + if err != nil { + exitErr(err) + } + case "shutdown": + fs := flag.NewFlagSet("shutdown", flag.ExitOnError) + reason := fs.String("reason", "manual", "shutdown reason") + fs.StringVar(&socketPath, "socket", socketPath, "unix socket path") + _ = fs.Parse(os.Args[2:]) + client = newClient(socketPath) + _, err := client.Shutdown(context.Background(), connect.NewRequest(&opentmuxv1.ShutdownRequest{Reason: *reason})) + if err != nil { + exitErr(err) + } + case "stats": + fs := flag.NewFlagSet("stats", flag.ExitOnError) + fs.StringVar(&socketPath, "socket", socketPath, "unix socket path") + _ = fs.Parse(os.Args[2:]) + client = newClient(socketPath) + resp, err := client.Stats(context.Background(), connect.NewRequest(&opentmuxv1.StatsRequest{})) + if err != nil { + exitErr(err) + } + fmt.Printf("tracked=%d pending=%d queue=%d\n", resp.Msg.TrackedSessions, resp.Msg.PendingSessions, resp.Msg.QueueDepth) + default: + fmt.Fprintln(os.Stderr, "unknown command") + os.Exit(2) + } +} + +func newClient(socketPath string) opentmuxv1connect.OpentmuxControlClient { + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + d := net.Dialer{Timeout: 2 * time.Second} + return d.DialContext(ctx, "unix", socketPath) + }, + } + httpClient := &http.Client{Transport: transport, Timeout: 5 * time.Second} + return opentmuxv1connect.NewOpentmuxControlClient(httpClient, "http://opentmuxd", connect.WithGRPC()) +} + +func exitErr(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/cmd/opentmuxd/main.go b/cmd/opentmuxd/main.go new file mode 100644 index 0000000..0243dde --- /dev/null +++ b/cmd/opentmuxd/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "flag" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/AnganSamadder/opentmux/gen/go/opentmux/v1/opentmuxv1connect" + "github.com/AnganSamadder/opentmux/internal/control" + "github.com/AnganSamadder/opentmux/internal/logging" +) + +func main() { + socketPath := flag.String("socket", filepath.Join(os.TempDir(), "opentmuxd.sock"), "unix socket path") + flag.Parse() + + _ = os.Remove(*socketPath) + listener, err := net.Listen("unix", *socketPath) + if err != nil { + panic(err) + } + defer func() { + _ = listener.Close() + _ = os.Remove(*socketPath) + }() + + _ = os.Chmod(*socketPath, 0o600) + + server := &http.Server{ + ReadTimeout: 10 * time.Second, + ReadHeaderTimeout: 10 * time.Second, + WriteTimeout: 20 * time.Second, + } + service := control.NewService(func(reason string) { + logging.Log("[opentmuxd] shutdown requested", map[string]any{"reason": reason}) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + }) + path, handler := opentmuxv1connect.NewOpentmuxControlHandler(service) + mux := http.NewServeMux() + mux.Handle(path, handler) + server.Handler = mux + + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) + sig := <-sigCh + logging.Log("[opentmuxd] shutdown signal", map[string]any{"signal": sig.String()}) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + }() + + logging.Log("[opentmuxd] listening", map[string]any{"socket": *socketPath}) + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + panic(err) + } +} diff --git a/docs/LOCAL_DEVELOPMENT.md b/docs/LOCAL_DEVELOPMENT.md index eca41d2..2548f02 100644 --- a/docs/LOCAL_DEVELOPMENT.md +++ b/docs/LOCAL_DEVELOPMENT.md @@ -12,6 +12,9 @@ cd opentmux # Install dependencies bun install +# Install Go toolchain (required for rewrite branch) +brew install go buf + # Build the project bun run build @@ -36,6 +39,7 @@ This runs `tsup` in watch mode, automatically rebuilding when you make changes. ```bash bun run build ``` +This runs TypeScript bundling and builds Go runtime binaries into `dist/runtime//`. ### Type Checking ```bash diff --git a/gen/go/opentmux/v1/opentmux.pb.go b/gen/go/opentmux/v1/opentmux.pb.go new file mode 100644 index 0000000..005549c --- /dev/null +++ b/gen/go/opentmux/v1/opentmux.pb.go @@ -0,0 +1,774 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: opentmux/v1/opentmux.proto + +package opentmuxv1 + +import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Config struct { + state protoimpl.MessageState `protogen:"open.v1"` + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + Layout string `protobuf:"bytes,3,opt,name=layout,proto3" json:"layout,omitempty"` + MainPaneSize uint32 `protobuf:"varint,4,opt,name=main_pane_size,json=mainPaneSize,proto3" json:"main_pane_size,omitempty"` + AutoClose bool `protobuf:"varint,5,opt,name=auto_close,json=autoClose,proto3" json:"auto_close,omitempty"` + SpawnDelayMs uint32 `protobuf:"varint,6,opt,name=spawn_delay_ms,json=spawnDelayMs,proto3" json:"spawn_delay_ms,omitempty"` + MaxRetryAttempts uint32 `protobuf:"varint,7,opt,name=max_retry_attempts,json=maxRetryAttempts,proto3" json:"max_retry_attempts,omitempty"` + LayoutDebounceMs uint32 `protobuf:"varint,8,opt,name=layout_debounce_ms,json=layoutDebounceMs,proto3" json:"layout_debounce_ms,omitempty"` + MaxAgentsPerColumn uint32 `protobuf:"varint,9,opt,name=max_agents_per_column,json=maxAgentsPerColumn,proto3" json:"max_agents_per_column,omitempty"` + ReaperEnabled bool `protobuf:"varint,10,opt,name=reaper_enabled,json=reaperEnabled,proto3" json:"reaper_enabled,omitempty"` + ReaperIntervalMs uint32 `protobuf:"varint,11,opt,name=reaper_interval_ms,json=reaperIntervalMs,proto3" json:"reaper_interval_ms,omitempty"` + ReaperMinZombieChecks uint32 `protobuf:"varint,12,opt,name=reaper_min_zombie_checks,json=reaperMinZombieChecks,proto3" json:"reaper_min_zombie_checks,omitempty"` + ReaperGracePeriodMs uint32 `protobuf:"varint,13,opt,name=reaper_grace_period_ms,json=reaperGracePeriodMs,proto3" json:"reaper_grace_period_ms,omitempty"` + ReaperAutoSelfDestruct bool `protobuf:"varint,14,opt,name=reaper_auto_self_destruct,json=reaperAutoSelfDestruct,proto3" json:"reaper_auto_self_destruct,omitempty"` + ReaperSelfDestructTimeoutMs uint32 `protobuf:"varint,15,opt,name=reaper_self_destruct_timeout_ms,json=reaperSelfDestructTimeoutMs,proto3" json:"reaper_self_destruct_timeout_ms,omitempty"` + RotatePort bool `protobuf:"varint,16,opt,name=rotate_port,json=rotatePort,proto3" json:"rotate_port,omitempty"` + MaxPorts uint32 `protobuf:"varint,17,opt,name=max_ports,json=maxPorts,proto3" json:"max_ports,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Config) Reset() { + *x = Config{} + mi := &file_opentmux_v1_opentmux_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_opentmux_v1_opentmux_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_opentmux_v1_opentmux_proto_rawDescGZIP(), []int{0} +} + +func (x *Config) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *Config) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *Config) GetLayout() string { + if x != nil { + return x.Layout + } + return "" +} + +func (x *Config) GetMainPaneSize() uint32 { + if x != nil { + return x.MainPaneSize + } + return 0 +} + +func (x *Config) GetAutoClose() bool { + if x != nil { + return x.AutoClose + } + return false +} + +func (x *Config) GetSpawnDelayMs() uint32 { + if x != nil { + return x.SpawnDelayMs + } + return 0 +} + +func (x *Config) GetMaxRetryAttempts() uint32 { + if x != nil { + return x.MaxRetryAttempts + } + return 0 +} + +func (x *Config) GetLayoutDebounceMs() uint32 { + if x != nil { + return x.LayoutDebounceMs + } + return 0 +} + +func (x *Config) GetMaxAgentsPerColumn() uint32 { + if x != nil { + return x.MaxAgentsPerColumn + } + return 0 +} + +func (x *Config) GetReaperEnabled() bool { + if x != nil { + return x.ReaperEnabled + } + return false +} + +func (x *Config) GetReaperIntervalMs() uint32 { + if x != nil { + return x.ReaperIntervalMs + } + return 0 +} + +func (x *Config) GetReaperMinZombieChecks() uint32 { + if x != nil { + return x.ReaperMinZombieChecks + } + return 0 +} + +func (x *Config) GetReaperGracePeriodMs() uint32 { + if x != nil { + return x.ReaperGracePeriodMs + } + return 0 +} + +func (x *Config) GetReaperAutoSelfDestruct() bool { + if x != nil { + return x.ReaperAutoSelfDestruct + } + return false +} + +func (x *Config) GetReaperSelfDestructTimeoutMs() uint32 { + if x != nil { + return x.ReaperSelfDestructTimeoutMs + } + return 0 +} + +func (x *Config) GetRotatePort() bool { + if x != nil { + return x.RotatePort + } + return false +} + +func (x *Config) GetMaxPorts() uint32 { + if x != nil { + return x.MaxPorts + } + return 0 +} + +type InitRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Directory string `protobuf:"bytes,1,opt,name=directory,proto3" json:"directory,omitempty"` + ServerUrl string `protobuf:"bytes,2,opt,name=server_url,json=serverUrl,proto3" json:"server_url,omitempty"` + Config *Config `protobuf:"bytes,3,opt,name=config,proto3" json:"config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InitRequest) Reset() { + *x = InitRequest{} + mi := &file_opentmux_v1_opentmux_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InitRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InitRequest) ProtoMessage() {} + +func (x *InitRequest) ProtoReflect() protoreflect.Message { + mi := &file_opentmux_v1_opentmux_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InitRequest.ProtoReflect.Descriptor instead. +func (*InitRequest) Descriptor() ([]byte, []int) { + return file_opentmux_v1_opentmux_proto_rawDescGZIP(), []int{1} +} + +func (x *InitRequest) GetDirectory() string { + if x != nil { + return x.Directory + } + return "" +} + +func (x *InitRequest) GetServerUrl() string { + if x != nil { + return x.ServerUrl + } + return "" +} + +func (x *InitRequest) GetConfig() *Config { + if x != nil { + return x.Config + } + return nil +} + +type InitResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InitResponse) Reset() { + *x = InitResponse{} + mi := &file_opentmux_v1_opentmux_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InitResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InitResponse) ProtoMessage() {} + +func (x *InitResponse) ProtoReflect() protoreflect.Message { + mi := &file_opentmux_v1_opentmux_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InitResponse.ProtoReflect.Descriptor instead. +func (*InitResponse) Descriptor() ([]byte, []int) { + return file_opentmux_v1_opentmux_proto_rawDescGZIP(), []int{2} +} + +func (x *InitResponse) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *InitResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type SessionCreatedInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + ParentId string `protobuf:"bytes,2,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"` + Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionCreatedInfo) Reset() { + *x = SessionCreatedInfo{} + mi := &file_opentmux_v1_opentmux_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionCreatedInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionCreatedInfo) ProtoMessage() {} + +func (x *SessionCreatedInfo) ProtoReflect() protoreflect.Message { + mi := &file_opentmux_v1_opentmux_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionCreatedInfo.ProtoReflect.Descriptor instead. +func (*SessionCreatedInfo) Descriptor() ([]byte, []int) { + return file_opentmux_v1_opentmux_proto_rawDescGZIP(), []int{3} +} + +func (x *SessionCreatedInfo) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *SessionCreatedInfo) GetParentId() string { + if x != nil { + return x.ParentId + } + return "" +} + +func (x *SessionCreatedInfo) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +type SessionCreatedRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + Info *SessionCreatedInfo `protobuf:"bytes,2,opt,name=info,proto3" json:"info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionCreatedRequest) Reset() { + *x = SessionCreatedRequest{} + mi := &file_opentmux_v1_opentmux_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionCreatedRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionCreatedRequest) ProtoMessage() {} + +func (x *SessionCreatedRequest) ProtoReflect() protoreflect.Message { + mi := &file_opentmux_v1_opentmux_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionCreatedRequest.ProtoReflect.Descriptor instead. +func (*SessionCreatedRequest) Descriptor() ([]byte, []int) { + return file_opentmux_v1_opentmux_proto_rawDescGZIP(), []int{4} +} + +func (x *SessionCreatedRequest) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *SessionCreatedRequest) GetInfo() *SessionCreatedInfo { + if x != nil { + return x.Info + } + return nil +} + +type SessionCreatedResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionCreatedResponse) Reset() { + *x = SessionCreatedResponse{} + mi := &file_opentmux_v1_opentmux_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionCreatedResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionCreatedResponse) ProtoMessage() {} + +func (x *SessionCreatedResponse) ProtoReflect() protoreflect.Message { + mi := &file_opentmux_v1_opentmux_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionCreatedResponse.ProtoReflect.Descriptor instead. +func (*SessionCreatedResponse) Descriptor() ([]byte, []int) { + return file_opentmux_v1_opentmux_proto_rawDescGZIP(), []int{5} +} + +func (x *SessionCreatedResponse) GetAccepted() bool { + if x != nil { + return x.Accepted + } + return false +} + +type ShutdownRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reason string `protobuf:"bytes,1,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ShutdownRequest) Reset() { + *x = ShutdownRequest{} + mi := &file_opentmux_v1_opentmux_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ShutdownRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ShutdownRequest) ProtoMessage() {} + +func (x *ShutdownRequest) ProtoReflect() protoreflect.Message { + mi := &file_opentmux_v1_opentmux_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ShutdownRequest.ProtoReflect.Descriptor instead. +func (*ShutdownRequest) Descriptor() ([]byte, []int) { + return file_opentmux_v1_opentmux_proto_rawDescGZIP(), []int{6} +} + +func (x *ShutdownRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type ShutdownResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ShutdownResponse) Reset() { + *x = ShutdownResponse{} + mi := &file_opentmux_v1_opentmux_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ShutdownResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ShutdownResponse) ProtoMessage() {} + +func (x *ShutdownResponse) ProtoReflect() protoreflect.Message { + mi := &file_opentmux_v1_opentmux_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ShutdownResponse.ProtoReflect.Descriptor instead. +func (*ShutdownResponse) Descriptor() ([]byte, []int) { + return file_opentmux_v1_opentmux_proto_rawDescGZIP(), []int{7} +} + +func (x *ShutdownResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +type StatsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatsRequest) Reset() { + *x = StatsRequest{} + mi := &file_opentmux_v1_opentmux_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatsRequest) ProtoMessage() {} + +func (x *StatsRequest) ProtoReflect() protoreflect.Message { + mi := &file_opentmux_v1_opentmux_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatsRequest.ProtoReflect.Descriptor instead. +func (*StatsRequest) Descriptor() ([]byte, []int) { + return file_opentmux_v1_opentmux_proto_rawDescGZIP(), []int{8} +} + +type StatsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + TrackedSessions uint64 `protobuf:"varint,1,opt,name=tracked_sessions,json=trackedSessions,proto3" json:"tracked_sessions,omitempty"` + PendingSessions uint64 `protobuf:"varint,2,opt,name=pending_sessions,json=pendingSessions,proto3" json:"pending_sessions,omitempty"` + QueueDepth uint64 `protobuf:"varint,3,opt,name=queue_depth,json=queueDepth,proto3" json:"queue_depth,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatsResponse) Reset() { + *x = StatsResponse{} + mi := &file_opentmux_v1_opentmux_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatsResponse) ProtoMessage() {} + +func (x *StatsResponse) ProtoReflect() protoreflect.Message { + mi := &file_opentmux_v1_opentmux_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatsResponse.ProtoReflect.Descriptor instead. +func (*StatsResponse) Descriptor() ([]byte, []int) { + return file_opentmux_v1_opentmux_proto_rawDescGZIP(), []int{9} +} + +func (x *StatsResponse) GetTrackedSessions() uint64 { + if x != nil { + return x.TrackedSessions + } + return 0 +} + +func (x *StatsResponse) GetPendingSessions() uint64 { + if x != nil { + return x.PendingSessions + } + return 0 +} + +func (x *StatsResponse) GetQueueDepth() uint64 { + if x != nil { + return x.QueueDepth + } + return 0 +} + +var File_opentmux_v1_opentmux_proto protoreflect.FileDescriptor + +const file_opentmux_v1_opentmux_proto_rawDesc = "" + + "\n" + + "\x1aopentmux/v1/opentmux.proto\x12\vopentmux.v1\x1a\x1bbuf/validate/validate.proto\"\xd5\x05\n" + + "\x06Config\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x12\n" + + "\x04port\x18\x02 \x01(\rR\x04port\x12\x16\n" + + "\x06layout\x18\x03 \x01(\tR\x06layout\x12/\n" + + "\x0emain_pane_size\x18\x04 \x01(\rB\t\xbaH\x06*\x04\x18P(\x14R\fmainPaneSize\x12\x1d\n" + + "\n" + + "auto_close\x18\x05 \x01(\bR\tautoClose\x12$\n" + + "\x0espawn_delay_ms\x18\x06 \x01(\rR\fspawnDelayMs\x12,\n" + + "\x12max_retry_attempts\x18\a \x01(\rR\x10maxRetryAttempts\x12,\n" + + "\x12layout_debounce_ms\x18\b \x01(\rR\x10layoutDebounceMs\x121\n" + + "\x15max_agents_per_column\x18\t \x01(\rR\x12maxAgentsPerColumn\x12%\n" + + "\x0ereaper_enabled\x18\n" + + " \x01(\bR\rreaperEnabled\x12,\n" + + "\x12reaper_interval_ms\x18\v \x01(\rR\x10reaperIntervalMs\x127\n" + + "\x18reaper_min_zombie_checks\x18\f \x01(\rR\x15reaperMinZombieChecks\x123\n" + + "\x16reaper_grace_period_ms\x18\r \x01(\rR\x13reaperGracePeriodMs\x129\n" + + "\x19reaper_auto_self_destruct\x18\x0e \x01(\bR\x16reaperAutoSelfDestruct\x12D\n" + + "\x1freaper_self_destruct_timeout_ms\x18\x0f \x01(\rR\x1breaperSelfDestructTimeoutMs\x12\x1f\n" + + "\vrotate_port\x18\x10 \x01(\bR\n" + + "rotatePort\x12\x1b\n" + + "\tmax_ports\x18\x11 \x01(\rR\bmaxPorts\"w\n" + + "\vInitRequest\x12\x1c\n" + + "\tdirectory\x18\x01 \x01(\tR\tdirectory\x12\x1d\n" + + "\n" + + "server_url\x18\x02 \x01(\tR\tserverUrl\x12+\n" + + "\x06config\x18\x03 \x01(\v2\x13.opentmux.v1.ConfigR\x06config\"B\n" + + "\fInitResponse\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"W\n" + + "\x12SessionCreatedInfo\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1b\n" + + "\tparent_id\x18\x02 \x01(\tR\bparentId\x12\x14\n" + + "\x05title\x18\x03 \x01(\tR\x05title\"`\n" + + "\x15SessionCreatedRequest\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x123\n" + + "\x04info\x18\x02 \x01(\v2\x1f.opentmux.v1.SessionCreatedInfoR\x04info\"4\n" + + "\x16SessionCreatedResponse\x12\x1a\n" + + "\baccepted\x18\x01 \x01(\bR\baccepted\")\n" + + "\x0fShutdownRequest\x12\x16\n" + + "\x06reason\x18\x01 \x01(\tR\x06reason\"\"\n" + + "\x10ShutdownResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\"\x0e\n" + + "\fStatsRequest\"\x86\x01\n" + + "\rStatsResponse\x12)\n" + + "\x10tracked_sessions\x18\x01 \x01(\x04R\x0ftrackedSessions\x12)\n" + + "\x10pending_sessions\x18\x02 \x01(\x04R\x0fpendingSessions\x12\x1f\n" + + "\vqueue_depth\x18\x03 \x01(\x04R\n" + + "queueDepth2\xb4\x02\n" + + "\x0fOpentmuxControl\x12;\n" + + "\x04Init\x12\x18.opentmux.v1.InitRequest\x1a\x19.opentmux.v1.InitResponse\x12[\n" + + "\x10OnSessionCreated\x12\".opentmux.v1.SessionCreatedRequest\x1a#.opentmux.v1.SessionCreatedResponse\x12G\n" + + "\bShutdown\x12\x1c.opentmux.v1.ShutdownRequest\x1a\x1d.opentmux.v1.ShutdownResponse\x12>\n" + + "\x05Stats\x12\x19.opentmux.v1.StatsRequest\x1a\x1a.opentmux.v1.StatsResponseB\xae\x01\n" + + "\x0fcom.opentmux.v1B\rOpentmuxProtoP\x01Z?github.com/AnganSamadder/opentmux/gen/go/opentmux/v1;opentmuxv1\xa2\x02\x03OXX\xaa\x02\vOpentmux.V1\xca\x02\vOpentmux\\V1\xe2\x02\x17Opentmux\\V1\\GPBMetadata\xea\x02\fOpentmux::V1b\x06proto3" + +var ( + file_opentmux_v1_opentmux_proto_rawDescOnce sync.Once + file_opentmux_v1_opentmux_proto_rawDescData []byte +) + +func file_opentmux_v1_opentmux_proto_rawDescGZIP() []byte { + file_opentmux_v1_opentmux_proto_rawDescOnce.Do(func() { + file_opentmux_v1_opentmux_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_opentmux_v1_opentmux_proto_rawDesc), len(file_opentmux_v1_opentmux_proto_rawDesc))) + }) + return file_opentmux_v1_opentmux_proto_rawDescData +} + +var file_opentmux_v1_opentmux_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_opentmux_v1_opentmux_proto_goTypes = []any{ + (*Config)(nil), // 0: opentmux.v1.Config + (*InitRequest)(nil), // 1: opentmux.v1.InitRequest + (*InitResponse)(nil), // 2: opentmux.v1.InitResponse + (*SessionCreatedInfo)(nil), // 3: opentmux.v1.SessionCreatedInfo + (*SessionCreatedRequest)(nil), // 4: opentmux.v1.SessionCreatedRequest + (*SessionCreatedResponse)(nil), // 5: opentmux.v1.SessionCreatedResponse + (*ShutdownRequest)(nil), // 6: opentmux.v1.ShutdownRequest + (*ShutdownResponse)(nil), // 7: opentmux.v1.ShutdownResponse + (*StatsRequest)(nil), // 8: opentmux.v1.StatsRequest + (*StatsResponse)(nil), // 9: opentmux.v1.StatsResponse +} +var file_opentmux_v1_opentmux_proto_depIdxs = []int32{ + 0, // 0: opentmux.v1.InitRequest.config:type_name -> opentmux.v1.Config + 3, // 1: opentmux.v1.SessionCreatedRequest.info:type_name -> opentmux.v1.SessionCreatedInfo + 1, // 2: opentmux.v1.OpentmuxControl.Init:input_type -> opentmux.v1.InitRequest + 4, // 3: opentmux.v1.OpentmuxControl.OnSessionCreated:input_type -> opentmux.v1.SessionCreatedRequest + 6, // 4: opentmux.v1.OpentmuxControl.Shutdown:input_type -> opentmux.v1.ShutdownRequest + 8, // 5: opentmux.v1.OpentmuxControl.Stats:input_type -> opentmux.v1.StatsRequest + 2, // 6: opentmux.v1.OpentmuxControl.Init:output_type -> opentmux.v1.InitResponse + 5, // 7: opentmux.v1.OpentmuxControl.OnSessionCreated:output_type -> opentmux.v1.SessionCreatedResponse + 7, // 8: opentmux.v1.OpentmuxControl.Shutdown:output_type -> opentmux.v1.ShutdownResponse + 9, // 9: opentmux.v1.OpentmuxControl.Stats:output_type -> opentmux.v1.StatsResponse + 6, // [6:10] is the sub-list for method output_type + 2, // [2:6] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_opentmux_v1_opentmux_proto_init() } +func file_opentmux_v1_opentmux_proto_init() { + if File_opentmux_v1_opentmux_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_opentmux_v1_opentmux_proto_rawDesc), len(file_opentmux_v1_opentmux_proto_rawDesc)), + NumEnums: 0, + NumMessages: 10, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_opentmux_v1_opentmux_proto_goTypes, + DependencyIndexes: file_opentmux_v1_opentmux_proto_depIdxs, + MessageInfos: file_opentmux_v1_opentmux_proto_msgTypes, + }.Build() + File_opentmux_v1_opentmux_proto = out.File + file_opentmux_v1_opentmux_proto_goTypes = nil + file_opentmux_v1_opentmux_proto_depIdxs = nil +} diff --git a/gen/go/opentmux/v1/opentmuxv1connect/opentmux.connect.go b/gen/go/opentmux/v1/opentmuxv1connect/opentmux.connect.go new file mode 100644 index 0000000..3c212f7 --- /dev/null +++ b/gen/go/opentmux/v1/opentmuxv1connect/opentmux.connect.go @@ -0,0 +1,194 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: opentmux/v1/opentmux.proto + +package opentmuxv1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + v1 "github.com/AnganSamadder/opentmux/gen/go/opentmux/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // OpentmuxControlName is the fully-qualified name of the OpentmuxControl service. + OpentmuxControlName = "opentmux.v1.OpentmuxControl" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // OpentmuxControlInitProcedure is the fully-qualified name of the OpentmuxControl's Init RPC. + OpentmuxControlInitProcedure = "/opentmux.v1.OpentmuxControl/Init" + // OpentmuxControlOnSessionCreatedProcedure is the fully-qualified name of the OpentmuxControl's + // OnSessionCreated RPC. + OpentmuxControlOnSessionCreatedProcedure = "/opentmux.v1.OpentmuxControl/OnSessionCreated" + // OpentmuxControlShutdownProcedure is the fully-qualified name of the OpentmuxControl's Shutdown + // RPC. + OpentmuxControlShutdownProcedure = "/opentmux.v1.OpentmuxControl/Shutdown" + // OpentmuxControlStatsProcedure is the fully-qualified name of the OpentmuxControl's Stats RPC. + OpentmuxControlStatsProcedure = "/opentmux.v1.OpentmuxControl/Stats" +) + +// OpentmuxControlClient is a client for the opentmux.v1.OpentmuxControl service. +type OpentmuxControlClient interface { + Init(context.Context, *connect.Request[v1.InitRequest]) (*connect.Response[v1.InitResponse], error) + OnSessionCreated(context.Context, *connect.Request[v1.SessionCreatedRequest]) (*connect.Response[v1.SessionCreatedResponse], error) + Shutdown(context.Context, *connect.Request[v1.ShutdownRequest]) (*connect.Response[v1.ShutdownResponse], error) + Stats(context.Context, *connect.Request[v1.StatsRequest]) (*connect.Response[v1.StatsResponse], error) +} + +// NewOpentmuxControlClient constructs a client for the opentmux.v1.OpentmuxControl service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewOpentmuxControlClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) OpentmuxControlClient { + baseURL = strings.TrimRight(baseURL, "/") + opentmuxControlMethods := v1.File_opentmux_v1_opentmux_proto.Services().ByName("OpentmuxControl").Methods() + return &opentmuxControlClient{ + init: connect.NewClient[v1.InitRequest, v1.InitResponse]( + httpClient, + baseURL+OpentmuxControlInitProcedure, + connect.WithSchema(opentmuxControlMethods.ByName("Init")), + connect.WithClientOptions(opts...), + ), + onSessionCreated: connect.NewClient[v1.SessionCreatedRequest, v1.SessionCreatedResponse]( + httpClient, + baseURL+OpentmuxControlOnSessionCreatedProcedure, + connect.WithSchema(opentmuxControlMethods.ByName("OnSessionCreated")), + connect.WithClientOptions(opts...), + ), + shutdown: connect.NewClient[v1.ShutdownRequest, v1.ShutdownResponse]( + httpClient, + baseURL+OpentmuxControlShutdownProcedure, + connect.WithSchema(opentmuxControlMethods.ByName("Shutdown")), + connect.WithClientOptions(opts...), + ), + stats: connect.NewClient[v1.StatsRequest, v1.StatsResponse]( + httpClient, + baseURL+OpentmuxControlStatsProcedure, + connect.WithSchema(opentmuxControlMethods.ByName("Stats")), + connect.WithClientOptions(opts...), + ), + } +} + +// opentmuxControlClient implements OpentmuxControlClient. +type opentmuxControlClient struct { + init *connect.Client[v1.InitRequest, v1.InitResponse] + onSessionCreated *connect.Client[v1.SessionCreatedRequest, v1.SessionCreatedResponse] + shutdown *connect.Client[v1.ShutdownRequest, v1.ShutdownResponse] + stats *connect.Client[v1.StatsRequest, v1.StatsResponse] +} + +// Init calls opentmux.v1.OpentmuxControl.Init. +func (c *opentmuxControlClient) Init(ctx context.Context, req *connect.Request[v1.InitRequest]) (*connect.Response[v1.InitResponse], error) { + return c.init.CallUnary(ctx, req) +} + +// OnSessionCreated calls opentmux.v1.OpentmuxControl.OnSessionCreated. +func (c *opentmuxControlClient) OnSessionCreated(ctx context.Context, req *connect.Request[v1.SessionCreatedRequest]) (*connect.Response[v1.SessionCreatedResponse], error) { + return c.onSessionCreated.CallUnary(ctx, req) +} + +// Shutdown calls opentmux.v1.OpentmuxControl.Shutdown. +func (c *opentmuxControlClient) Shutdown(ctx context.Context, req *connect.Request[v1.ShutdownRequest]) (*connect.Response[v1.ShutdownResponse], error) { + return c.shutdown.CallUnary(ctx, req) +} + +// Stats calls opentmux.v1.OpentmuxControl.Stats. +func (c *opentmuxControlClient) Stats(ctx context.Context, req *connect.Request[v1.StatsRequest]) (*connect.Response[v1.StatsResponse], error) { + return c.stats.CallUnary(ctx, req) +} + +// OpentmuxControlHandler is an implementation of the opentmux.v1.OpentmuxControl service. +type OpentmuxControlHandler interface { + Init(context.Context, *connect.Request[v1.InitRequest]) (*connect.Response[v1.InitResponse], error) + OnSessionCreated(context.Context, *connect.Request[v1.SessionCreatedRequest]) (*connect.Response[v1.SessionCreatedResponse], error) + Shutdown(context.Context, *connect.Request[v1.ShutdownRequest]) (*connect.Response[v1.ShutdownResponse], error) + Stats(context.Context, *connect.Request[v1.StatsRequest]) (*connect.Response[v1.StatsResponse], error) +} + +// NewOpentmuxControlHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewOpentmuxControlHandler(svc OpentmuxControlHandler, opts ...connect.HandlerOption) (string, http.Handler) { + opentmuxControlMethods := v1.File_opentmux_v1_opentmux_proto.Services().ByName("OpentmuxControl").Methods() + opentmuxControlInitHandler := connect.NewUnaryHandler( + OpentmuxControlInitProcedure, + svc.Init, + connect.WithSchema(opentmuxControlMethods.ByName("Init")), + connect.WithHandlerOptions(opts...), + ) + opentmuxControlOnSessionCreatedHandler := connect.NewUnaryHandler( + OpentmuxControlOnSessionCreatedProcedure, + svc.OnSessionCreated, + connect.WithSchema(opentmuxControlMethods.ByName("OnSessionCreated")), + connect.WithHandlerOptions(opts...), + ) + opentmuxControlShutdownHandler := connect.NewUnaryHandler( + OpentmuxControlShutdownProcedure, + svc.Shutdown, + connect.WithSchema(opentmuxControlMethods.ByName("Shutdown")), + connect.WithHandlerOptions(opts...), + ) + opentmuxControlStatsHandler := connect.NewUnaryHandler( + OpentmuxControlStatsProcedure, + svc.Stats, + connect.WithSchema(opentmuxControlMethods.ByName("Stats")), + connect.WithHandlerOptions(opts...), + ) + return "/opentmux.v1.OpentmuxControl/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case OpentmuxControlInitProcedure: + opentmuxControlInitHandler.ServeHTTP(w, r) + case OpentmuxControlOnSessionCreatedProcedure: + opentmuxControlOnSessionCreatedHandler.ServeHTTP(w, r) + case OpentmuxControlShutdownProcedure: + opentmuxControlShutdownHandler.ServeHTTP(w, r) + case OpentmuxControlStatsProcedure: + opentmuxControlStatsHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedOpentmuxControlHandler returns CodeUnimplemented from all methods. +type UnimplementedOpentmuxControlHandler struct{} + +func (UnimplementedOpentmuxControlHandler) Init(context.Context, *connect.Request[v1.InitRequest]) (*connect.Response[v1.InitResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("opentmux.v1.OpentmuxControl.Init is not implemented")) +} + +func (UnimplementedOpentmuxControlHandler) OnSessionCreated(context.Context, *connect.Request[v1.SessionCreatedRequest]) (*connect.Response[v1.SessionCreatedResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("opentmux.v1.OpentmuxControl.OnSessionCreated is not implemented")) +} + +func (UnimplementedOpentmuxControlHandler) Shutdown(context.Context, *connect.Request[v1.ShutdownRequest]) (*connect.Response[v1.ShutdownResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("opentmux.v1.OpentmuxControl.Shutdown is not implemented")) +} + +func (UnimplementedOpentmuxControlHandler) Stats(context.Context, *connect.Request[v1.StatsRequest]) (*connect.Response[v1.StatsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("opentmux.v1.OpentmuxControl.Stats is not implemented")) +} diff --git a/gen/ts/opentmux/v1/opentmux_connect.ts b/gen/ts/opentmux/v1/opentmux_connect.ts new file mode 100644 index 0000000..d1f53d2 --- /dev/null +++ b/gen/ts/opentmux/v1/opentmux_connect.ts @@ -0,0 +1,53 @@ +// @generated by protoc-gen-connect-es v1.6.1 with parameter "target=ts,import_extension=none" +// @generated from file opentmux/v1/opentmux.proto (package opentmux.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import { InitRequest, InitResponse, SessionCreatedRequest, SessionCreatedResponse, ShutdownRequest, ShutdownResponse, StatsRequest, StatsResponse } from "./opentmux_pb"; +import { MethodKind } from "@bufbuild/protobuf"; + +/** + * @generated from service opentmux.v1.OpentmuxControl + */ +export const OpentmuxControl = { + typeName: "opentmux.v1.OpentmuxControl", + methods: { + /** + * @generated from rpc opentmux.v1.OpentmuxControl.Init + */ + init: { + name: "Init", + I: InitRequest, + O: InitResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc opentmux.v1.OpentmuxControl.OnSessionCreated + */ + onSessionCreated: { + name: "OnSessionCreated", + I: SessionCreatedRequest, + O: SessionCreatedResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc opentmux.v1.OpentmuxControl.Shutdown + */ + shutdown: { + name: "Shutdown", + I: ShutdownRequest, + O: ShutdownResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc opentmux.v1.OpentmuxControl.Stats + */ + stats: { + name: "Stats", + I: StatsRequest, + O: StatsResponse, + kind: MethodKind.Unary, + }, + } +} as const; + diff --git a/gen/ts/opentmux/v1/opentmux_pb.ts b/gen/ts/opentmux/v1/opentmux_pb.ts new file mode 100644 index 0000000..7013c61 --- /dev/null +++ b/gen/ts/opentmux/v1/opentmux_pb.ts @@ -0,0 +1,340 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=none" +// @generated from file opentmux/v1/opentmux.proto (package opentmux.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file opentmux/v1/opentmux.proto. + */ +export const file_opentmux_v1_opentmux: GenFile = /*@__PURE__*/ + fileDesc("ChpvcGVudG11eC92MS9vcGVudG11eC5wcm90bxILb3BlbnRtdXgudjEixwMKBkNvbmZpZxIPCgdlbmFibGVkGAEgASgIEgwKBHBvcnQYAiABKA0SDgoGbGF5b3V0GAMgASgJEiEKDm1haW5fcGFuZV9zaXplGAQgASgNQgm6SAYqBBhQKBQSEgoKYXV0b19jbG9zZRgFIAEoCBIWCg5zcGF3bl9kZWxheV9tcxgGIAEoDRIaChJtYXhfcmV0cnlfYXR0ZW1wdHMYByABKA0SGgoSbGF5b3V0X2RlYm91bmNlX21zGAggASgNEh0KFW1heF9hZ2VudHNfcGVyX2NvbHVtbhgJIAEoDRIWCg5yZWFwZXJfZW5hYmxlZBgKIAEoCBIaChJyZWFwZXJfaW50ZXJ2YWxfbXMYCyABKA0SIAoYcmVhcGVyX21pbl96b21iaWVfY2hlY2tzGAwgASgNEh4KFnJlYXBlcl9ncmFjZV9wZXJpb2RfbXMYDSABKA0SIQoZcmVhcGVyX2F1dG9fc2VsZl9kZXN0cnVjdBgOIAEoCBInCh9yZWFwZXJfc2VsZl9kZXN0cnVjdF90aW1lb3V0X21zGA8gASgNEhMKC3JvdGF0ZV9wb3J0GBAgASgIEhEKCW1heF9wb3J0cxgRIAEoDSJZCgtJbml0UmVxdWVzdBIRCglkaXJlY3RvcnkYASABKAkSEgoKc2VydmVyX3VybBgCIAEoCRIjCgZjb25maWcYAyABKAsyEy5vcGVudG11eC52MS5Db25maWciMAoMSW5pdFJlc3BvbnNlEg8KB2VuYWJsZWQYASABKAgSDwoHbWVzc2FnZRgCIAEoCSJCChJTZXNzaW9uQ3JlYXRlZEluZm8SCgoCaWQYASABKAkSEQoJcGFyZW50X2lkGAIgASgJEg0KBXRpdGxlGAMgASgJIlQKFVNlc3Npb25DcmVhdGVkUmVxdWVzdBIMCgR0eXBlGAEgASgJEi0KBGluZm8YAiABKAsyHy5vcGVudG11eC52MS5TZXNzaW9uQ3JlYXRlZEluZm8iKgoWU2Vzc2lvbkNyZWF0ZWRSZXNwb25zZRIQCghhY2NlcHRlZBgBIAEoCCIhCg9TaHV0ZG93blJlcXVlc3QSDgoGcmVhc29uGAEgASgJIh4KEFNodXRkb3duUmVzcG9uc2USCgoCb2sYASABKAgiDgoMU3RhdHNSZXF1ZXN0IlgKDVN0YXRzUmVzcG9uc2USGAoQdHJhY2tlZF9zZXNzaW9ucxgBIAEoBBIYChBwZW5kaW5nX3Nlc3Npb25zGAIgASgEEhMKC3F1ZXVlX2RlcHRoGAMgASgEMrQCCg9PcGVudG11eENvbnRyb2wSOwoESW5pdBIYLm9wZW50bXV4LnYxLkluaXRSZXF1ZXN0Ghkub3BlbnRtdXgudjEuSW5pdFJlc3BvbnNlElsKEE9uU2Vzc2lvbkNyZWF0ZWQSIi5vcGVudG11eC52MS5TZXNzaW9uQ3JlYXRlZFJlcXVlc3QaIy5vcGVudG11eC52MS5TZXNzaW9uQ3JlYXRlZFJlc3BvbnNlEkcKCFNodXRkb3duEhwub3BlbnRtdXgudjEuU2h1dGRvd25SZXF1ZXN0Gh0ub3BlbnRtdXgudjEuU2h1dGRvd25SZXNwb25zZRI+CgVTdGF0cxIZLm9wZW50bXV4LnYxLlN0YXRzUmVxdWVzdBoaLm9wZW50bXV4LnYxLlN0YXRzUmVzcG9uc2VCrgEKD2NvbS5vcGVudG11eC52MUINT3BlbnRtdXhQcm90b1ABWj9naXRodWIuY29tL0FuZ2FuU2FtYWRkZXIvb3BlbnRtdXgvZ2VuL2dvL29wZW50bXV4L3YxO29wZW50bXV4djGiAgNPWFiqAgtPcGVudG11eC5WMcoCC09wZW50bXV4XFYx4gIXT3BlbnRtdXhcVjFcR1BCTWV0YWRhdGHqAgxPcGVudG11eDo6VjFiBnByb3RvMw", [file_buf_validate_validate]); + +/** + * @generated from message opentmux.v1.Config + */ +export type Config = Message<"opentmux.v1.Config"> & { + /** + * @generated from field: bool enabled = 1; + */ + enabled: boolean; + + /** + * @generated from field: uint32 port = 2; + */ + port: number; + + /** + * @generated from field: string layout = 3; + */ + layout: string; + + /** + * @generated from field: uint32 main_pane_size = 4; + */ + mainPaneSize: number; + + /** + * @generated from field: bool auto_close = 5; + */ + autoClose: boolean; + + /** + * @generated from field: uint32 spawn_delay_ms = 6; + */ + spawnDelayMs: number; + + /** + * @generated from field: uint32 max_retry_attempts = 7; + */ + maxRetryAttempts: number; + + /** + * @generated from field: uint32 layout_debounce_ms = 8; + */ + layoutDebounceMs: number; + + /** + * @generated from field: uint32 max_agents_per_column = 9; + */ + maxAgentsPerColumn: number; + + /** + * @generated from field: bool reaper_enabled = 10; + */ + reaperEnabled: boolean; + + /** + * @generated from field: uint32 reaper_interval_ms = 11; + */ + reaperIntervalMs: number; + + /** + * @generated from field: uint32 reaper_min_zombie_checks = 12; + */ + reaperMinZombieChecks: number; + + /** + * @generated from field: uint32 reaper_grace_period_ms = 13; + */ + reaperGracePeriodMs: number; + + /** + * @generated from field: bool reaper_auto_self_destruct = 14; + */ + reaperAutoSelfDestruct: boolean; + + /** + * @generated from field: uint32 reaper_self_destruct_timeout_ms = 15; + */ + reaperSelfDestructTimeoutMs: number; + + /** + * @generated from field: bool rotate_port = 16; + */ + rotatePort: boolean; + + /** + * @generated from field: uint32 max_ports = 17; + */ + maxPorts: number; +}; + +/** + * Describes the message opentmux.v1.Config. + * Use `create(ConfigSchema)` to create a new message. + */ +export const ConfigSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_opentmux_v1_opentmux, 0); + +/** + * @generated from message opentmux.v1.InitRequest + */ +export type InitRequest = Message<"opentmux.v1.InitRequest"> & { + /** + * @generated from field: string directory = 1; + */ + directory: string; + + /** + * @generated from field: string server_url = 2; + */ + serverUrl: string; + + /** + * @generated from field: opentmux.v1.Config config = 3; + */ + config?: Config; +}; + +/** + * Describes the message opentmux.v1.InitRequest. + * Use `create(InitRequestSchema)` to create a new message. + */ +export const InitRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_opentmux_v1_opentmux, 1); + +/** + * @generated from message opentmux.v1.InitResponse + */ +export type InitResponse = Message<"opentmux.v1.InitResponse"> & { + /** + * @generated from field: bool enabled = 1; + */ + enabled: boolean; + + /** + * @generated from field: string message = 2; + */ + message: string; +}; + +/** + * Describes the message opentmux.v1.InitResponse. + * Use `create(InitResponseSchema)` to create a new message. + */ +export const InitResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_opentmux_v1_opentmux, 2); + +/** + * @generated from message opentmux.v1.SessionCreatedInfo + */ +export type SessionCreatedInfo = Message<"opentmux.v1.SessionCreatedInfo"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string parent_id = 2; + */ + parentId: string; + + /** + * @generated from field: string title = 3; + */ + title: string; +}; + +/** + * Describes the message opentmux.v1.SessionCreatedInfo. + * Use `create(SessionCreatedInfoSchema)` to create a new message. + */ +export const SessionCreatedInfoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_opentmux_v1_opentmux, 3); + +/** + * @generated from message opentmux.v1.SessionCreatedRequest + */ +export type SessionCreatedRequest = Message<"opentmux.v1.SessionCreatedRequest"> & { + /** + * @generated from field: string type = 1; + */ + type: string; + + /** + * @generated from field: opentmux.v1.SessionCreatedInfo info = 2; + */ + info?: SessionCreatedInfo; +}; + +/** + * Describes the message opentmux.v1.SessionCreatedRequest. + * Use `create(SessionCreatedRequestSchema)` to create a new message. + */ +export const SessionCreatedRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_opentmux_v1_opentmux, 4); + +/** + * @generated from message opentmux.v1.SessionCreatedResponse + */ +export type SessionCreatedResponse = Message<"opentmux.v1.SessionCreatedResponse"> & { + /** + * @generated from field: bool accepted = 1; + */ + accepted: boolean; +}; + +/** + * Describes the message opentmux.v1.SessionCreatedResponse. + * Use `create(SessionCreatedResponseSchema)` to create a new message. + */ +export const SessionCreatedResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_opentmux_v1_opentmux, 5); + +/** + * @generated from message opentmux.v1.ShutdownRequest + */ +export type ShutdownRequest = Message<"opentmux.v1.ShutdownRequest"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message opentmux.v1.ShutdownRequest. + * Use `create(ShutdownRequestSchema)` to create a new message. + */ +export const ShutdownRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_opentmux_v1_opentmux, 6); + +/** + * @generated from message opentmux.v1.ShutdownResponse + */ +export type ShutdownResponse = Message<"opentmux.v1.ShutdownResponse"> & { + /** + * @generated from field: bool ok = 1; + */ + ok: boolean; +}; + +/** + * Describes the message opentmux.v1.ShutdownResponse. + * Use `create(ShutdownResponseSchema)` to create a new message. + */ +export const ShutdownResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_opentmux_v1_opentmux, 7); + +/** + * @generated from message opentmux.v1.StatsRequest + */ +export type StatsRequest = Message<"opentmux.v1.StatsRequest"> & { +}; + +/** + * Describes the message opentmux.v1.StatsRequest. + * Use `create(StatsRequestSchema)` to create a new message. + */ +export const StatsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_opentmux_v1_opentmux, 8); + +/** + * @generated from message opentmux.v1.StatsResponse + */ +export type StatsResponse = Message<"opentmux.v1.StatsResponse"> & { + /** + * @generated from field: uint64 tracked_sessions = 1; + */ + trackedSessions: bigint; + + /** + * @generated from field: uint64 pending_sessions = 2; + */ + pendingSessions: bigint; + + /** + * @generated from field: uint64 queue_depth = 3; + */ + queueDepth: bigint; +}; + +/** + * Describes the message opentmux.v1.StatsResponse. + * Use `create(StatsResponseSchema)` to create a new message. + */ +export const StatsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_opentmux_v1_opentmux, 9); + +/** + * @generated from service opentmux.v1.OpentmuxControl + */ +export const OpentmuxControl: GenService<{ + /** + * @generated from rpc opentmux.v1.OpentmuxControl.Init + */ + init: { + methodKind: "unary"; + input: typeof InitRequestSchema; + output: typeof InitResponseSchema; + }, + /** + * @generated from rpc opentmux.v1.OpentmuxControl.OnSessionCreated + */ + onSessionCreated: { + methodKind: "unary"; + input: typeof SessionCreatedRequestSchema; + output: typeof SessionCreatedResponseSchema; + }, + /** + * @generated from rpc opentmux.v1.OpentmuxControl.Shutdown + */ + shutdown: { + methodKind: "unary"; + input: typeof ShutdownRequestSchema; + output: typeof ShutdownResponseSchema; + }, + /** + * @generated from rpc opentmux.v1.OpentmuxControl.Stats + */ + stats: { + methodKind: "unary"; + input: typeof StatsRequestSchema; + output: typeof StatsResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_opentmux_v1_opentmux, 0); + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c6fc115 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/AnganSamadder/opentmux + +go 1.25 + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 + connectrpc.com/connect v1.19.1 + google.golang.org/protobuf v1.36.10 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9d38b5a --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 h1:DQLS/rRxLHuugVzjJU5AvOwD57pdFl9he/0O7e5P294= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1/go.mod h1:aY3zbkNan5F+cGm9lITDP6oxJIwu0dn9KjJuJjWaHkg= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c04377e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,148 @@ +package config + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +type Config struct { + Enabled bool `json:"enabled"` + Port int `json:"port"` + Layout string `json:"layout"` + MainPaneSize int `json:"main_pane_size"` + AutoClose bool `json:"auto_close"` + SpawnDelayMs int `json:"spawn_delay_ms"` + MaxRetryAttempts int `json:"max_retry_attempts"` + LayoutDebounceMs int `json:"layout_debounce_ms"` + MaxAgentsPerColumn int `json:"max_agents_per_column"` + ReaperEnabled bool `json:"reaper_enabled"` + ReaperIntervalMs int `json:"reaper_interval_ms"` + ReaperMinZombieChecks int `json:"reaper_min_zombie_checks"` + ReaperGracePeriodMs int `json:"reaper_grace_period_ms"` + ReaperAutoSelfDestruct bool `json:"reaper_auto_self_destruct"` + ReaperSelfDestructTimeoutMs int `json:"reaper_self_destruct_timeout_ms"` + RotatePort bool `json:"rotate_port"` + MaxPorts int `json:"max_ports"` +} + +func DefaultConfig() Config { + return Config{ + Enabled: true, + Port: 4096, + Layout: "main-vertical", + MainPaneSize: 60, + AutoClose: true, + SpawnDelayMs: 300, + MaxRetryAttempts: 2, + LayoutDebounceMs: 150, + MaxAgentsPerColumn: 3, + ReaperEnabled: true, + ReaperIntervalMs: 30000, + ReaperMinZombieChecks: 3, + ReaperGracePeriodMs: 5000, + ReaperAutoSelfDestruct: true, + ReaperSelfDestructTimeoutMs: 60 * 60 * 1000, + RotatePort: false, + MaxPorts: 10, + } +} + +func (c *Config) Normalize() { + if c.Port <= 0 { + c.Port = 4096 + } + if c.Layout == "" { + c.Layout = "main-vertical" + } + if c.MainPaneSize < 20 || c.MainPaneSize > 80 { + c.MainPaneSize = 60 + } + if c.SpawnDelayMs < 50 || c.SpawnDelayMs > 2000 { + c.SpawnDelayMs = 300 + } + if c.MaxRetryAttempts < 0 || c.MaxRetryAttempts > 5 { + c.MaxRetryAttempts = 2 + } + if c.LayoutDebounceMs < 50 || c.LayoutDebounceMs > 1000 { + c.LayoutDebounceMs = 150 + } + if c.MaxAgentsPerColumn < 1 || c.MaxAgentsPerColumn > 10 { + c.MaxAgentsPerColumn = 3 + } + if c.MaxPorts < 1 || c.MaxPorts > 100 { + c.MaxPorts = 10 + } +} + +func Merge(base Config, override Config) Config { + result := base + b, _ := json.Marshal(override) + _ = json.Unmarshal(b, &result) + result.Normalize() + return result +} + +func parseConfigFile(path string) (Config, error) { + content, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + cfg := DefaultConfig() + if err := json.Unmarshal(content, &cfg); err != nil { + return Config{}, err + } + cfg.Normalize() + return cfg, nil +} + +func LoadConfig(directory string) Config { + cfg := DefaultConfig() + paths := make([]string, 0, 3) + + if directory != "" { + paths = append(paths, + filepath.Join(directory, "opentmux.json"), + filepath.Join(directory, "opencode-agent-tmux.json"), + ) + } + + home := os.Getenv("HOME") + if home != "" { + paths = append(paths, filepath.Join(home, ".config", "opencode", "opentmux.json")) + } + + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + parsed, err := parseConfigFile(p) + if err == nil { + return parsed + } + } + } + + cfg.Normalize() + return cfg +} + +func ParseJSON(raw string) (Config, error) { + if raw == "" { + cfg := DefaultConfig() + cfg.Normalize() + return cfg, nil + } + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return Config{}, err + } + cfg.Normalize() + return cfg, nil +} + +func Validate(cfg Config) error { + if cfg.Layout == "" { + return errors.New("layout is required") + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..44031ec --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,61 @@ +package config + +import "testing" + +func TestDefaultConfigNormalization(t *testing.T) { + cfg := DefaultConfig() + cfg.Normalize() + if cfg.Port != 4096 { + t.Fatalf("expected default port 4096, got %d", cfg.Port) + } + if cfg.Layout == "" { + t.Fatal("expected default layout to be set") + } +} + +func TestNormalizeClampsOutOfRangeValues(t *testing.T) { + cfg := Config{ + Port: -1, + Layout: "", + MainPaneSize: 100, + SpawnDelayMs: 10, + MaxRetryAttempts: 10, + LayoutDebounceMs: 10, + MaxAgentsPerColumn: 0, + MaxPorts: 1000, + } + cfg.Normalize() + + if cfg.Port != 4096 || cfg.Layout != "main-vertical" || cfg.MainPaneSize != 60 { + t.Fatalf("normalize failed for base fields: %+v", cfg) + } + if cfg.SpawnDelayMs != 300 || cfg.MaxRetryAttempts != 2 || cfg.LayoutDebounceMs != 150 { + t.Fatalf("normalize failed for timing/retry fields: %+v", cfg) + } + if cfg.MaxAgentsPerColumn != 3 || cfg.MaxPorts != 10 { + t.Fatalf("normalize failed for caps: %+v", cfg) + } +} + +func TestParseJSON(t *testing.T) { + cfg, err := ParseJSON(`{"port":5000,"layout":"tiled","max_ports":5}`) + if err != nil { + t.Fatalf("expected parse success, got %v", err) + } + if cfg.Port != 5000 || cfg.Layout != "tiled" || cfg.MaxPorts != 5 { + t.Fatalf("unexpected parsed config: %+v", cfg) + } + + if _, err := ParseJSON("{invalid}"); err == nil { + t.Fatal("expected parse error for invalid json") + } +} + +func TestMergeOverride(t *testing.T) { + base := DefaultConfig() + override := Config{Port: 7777, Layout: "tiled", MaxPorts: 20} + merged := Merge(base, override) + if merged.Port != 7777 || merged.Layout != "tiled" || merged.MaxPorts != 20 { + t.Fatalf("expected override fields to apply, got %+v", merged) + } +} diff --git a/internal/control/service.go b/internal/control/service.go new file mode 100644 index 0000000..582463f --- /dev/null +++ b/internal/control/service.go @@ -0,0 +1,113 @@ +package control + +import ( + "context" + "sync" + + "connectrpc.com/connect" + "github.com/AnganSamadder/opentmux/gen/go/opentmux/v1" + "github.com/AnganSamadder/opentmux/internal/config" + "github.com/AnganSamadder/opentmux/internal/logging" + "github.com/AnganSamadder/opentmux/internal/metrics" + "github.com/AnganSamadder/opentmux/internal/sessionmanager" +) + +type Service struct { + mu sync.Mutex + manager *sessionmanager.Manager + metrics *metrics.Metrics + onStop func(string) +} + +func NewService(onStop func(string)) *Service { + return &Service{metrics: metrics.New(), onStop: onStop} +} + +func (s *Service) Init(ctx context.Context, req *connect.Request[opentmuxv1.InitRequest]) (*connect.Response[opentmuxv1.InitResponse], error) { + s.mu.Lock() + defer s.mu.Unlock() + + cfg := config.LoadConfig(req.Msg.Directory) + if req.Msg.Config != nil { + cfg = config.Merge(cfg, fromProtoConfig(req.Msg.Config)) + } + + s.manager = sessionmanager.New(cfg, req.Msg.ServerUrl, s.metrics) + logging.Log("[control] initialized", map[string]any{"directory": req.Msg.Directory, "serverUrl": req.Msg.ServerUrl}) + + return connect.NewResponse(&opentmuxv1.InitResponse{ + Enabled: cfg.Enabled, + Message: "initialized", + }), nil +} + +func (s *Service) OnSessionCreated(ctx context.Context, req *connect.Request[opentmuxv1.SessionCreatedRequest]) (*connect.Response[opentmuxv1.SessionCreatedResponse], error) { + s.mu.Lock() + manager := s.manager + s.mu.Unlock() + + if manager == nil { + return connect.NewResponse(&opentmuxv1.SessionCreatedResponse{Accepted: false}), nil + } + + info := req.Msg.GetInfo() + accepted := manager.OnSessionCreated(ctx, sessionmanager.SessionEvent{ + Type: req.Msg.GetType(), + ID: info.GetId(), + ParentID: info.GetParentId(), + Title: info.GetTitle(), + }) + + return connect.NewResponse(&opentmuxv1.SessionCreatedResponse{Accepted: accepted}), nil +} + +func (s *Service) Shutdown(_ context.Context, req *connect.Request[opentmuxv1.ShutdownRequest]) (*connect.Response[opentmuxv1.ShutdownResponse], error) { + s.mu.Lock() + manager := s.manager + s.manager = nil + onStop := s.onStop + s.mu.Unlock() + + if manager != nil { + manager.Cleanup(req.Msg.GetReason()) + } + if onStop != nil { + go onStop(req.Msg.GetReason()) + } + + return connect.NewResponse(&opentmuxv1.ShutdownResponse{Ok: true}), nil +} + +func (s *Service) Stats(_ context.Context, _ *connect.Request[opentmuxv1.StatsRequest]) (*connect.Response[opentmuxv1.StatsResponse], error) { + snap := s.metrics.Snapshot() + return connect.NewResponse(&opentmuxv1.StatsResponse{ + TrackedSessions: snap.TrackedSessions, + PendingSessions: snap.PendingSessions, + QueueDepth: snap.QueueDepth, + }), nil +} + +func fromProtoConfig(in *opentmuxv1.Config) config.Config { + if in == nil { + return config.DefaultConfig() + } + return config.Config{ + Enabled: in.GetEnabled(), + Port: int(in.GetPort()), + Layout: in.GetLayout(), + MainPaneSize: int(in.GetMainPaneSize()), + AutoClose: in.GetAutoClose(), + SpawnDelayMs: int(in.GetSpawnDelayMs()), + MaxRetryAttempts: int(in.GetMaxRetryAttempts()), + LayoutDebounceMs: int(in.GetLayoutDebounceMs()), + MaxAgentsPerColumn: int(in.GetMaxAgentsPerColumn()), + ReaperEnabled: in.GetReaperEnabled(), + ReaperIntervalMs: int(in.GetReaperIntervalMs()), + ReaperMinZombieChecks: int(in.GetReaperMinZombieChecks()), + ReaperGracePeriodMs: int(in.GetReaperGracePeriodMs()), + ReaperAutoSelfDestruct: in.GetReaperAutoSelfDestruct(), + ReaperSelfDestructTimeoutMs: int(in.GetReaperSelfDestructTimeoutMs()), + RotatePort: in.GetRotatePort(), + MaxPorts: int(in.GetMaxPorts()), + } +} diff --git a/internal/control/service_test.go b/internal/control/service_test.go new file mode 100644 index 0000000..cfdf7c1 --- /dev/null +++ b/internal/control/service_test.go @@ -0,0 +1,89 @@ +package control + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "connectrpc.com/connect" + opentmuxv1 "github.com/AnganSamadder/opentmux/gen/go/opentmux/v1" +) + +func TestServiceOnSessionCreatedBeforeInitIsRejected(t *testing.T) { + svc := NewService(nil) + resp, err := svc.OnSessionCreated(context.Background(), connect.NewRequest(&opentmuxv1.SessionCreatedRequest{ + Type: "session.created", + Info: &opentmuxv1.SessionCreatedInfo{Id: "ses_1", ParentId: "ses_p", Title: "t"}, + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Msg.Accepted { + t.Fatal("expected session event rejection before Init") + } +} + +func TestServiceInitStatsShutdownLifecycle(t *testing.T) { + stopped := make(chan string, 1) + svc := NewService(func(reason string) { + stopped <- reason + }) + + initResp, err := svc.Init(context.Background(), connect.NewRequest(&opentmuxv1.InitRequest{ + Directory: "", + ServerUrl: "http://localhost:4096", + })) + if err != nil { + t.Fatalf("init error: %v", err) + } + if initResp.Msg.Message == "" { + t.Fatal("expected init message") + } + + statsResp, err := svc.Stats(context.Background(), connect.NewRequest(&opentmuxv1.StatsRequest{})) + if err != nil { + t.Fatalf("stats error: %v", err) + } + if statsResp.Msg.TrackedSessions != 0 || statsResp.Msg.PendingSessions != 0 { + t.Fatalf("expected zeroed stats, got %+v", statsResp.Msg) + } + + shutdownResp, err := svc.Shutdown(context.Background(), connect.NewRequest(&opentmuxv1.ShutdownRequest{Reason: "test"})) + if err != nil { + t.Fatalf("shutdown error: %v", err) + } + if !shutdownResp.Msg.Ok { + t.Fatal("expected shutdown ok") + } + + select { + case reason := <-stopped: + if reason != "test" { + t.Fatalf("unexpected stop reason: %s", reason) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("expected onStop callback") + } +} + +func TestServiceShutdownCallbackCalledOncePerShutdown(t *testing.T) { + var calls atomic.Int32 + svc := NewService(func(string) { + calls.Add(1) + }) + + _, _ = svc.Shutdown(context.Background(), connect.NewRequest(&opentmuxv1.ShutdownRequest{Reason: "1"})) + _, _ = svc.Shutdown(context.Background(), connect.NewRequest(&opentmuxv1.ShutdownRequest{Reason: "2"})) + + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if calls.Load() == 2 { + return + } + time.Sleep(10 * time.Millisecond) + } + if got := calls.Load(); got != 2 { + t.Fatalf("expected callback on each shutdown request, got %d", got) + } +} diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..d4a9db1 --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,47 @@ +package logging + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +var ( + logMu sync.Mutex + logFile = filepath.Join(os.TempDir(), "opencode-agent-tmux.log") +) + +func SetLogFile(path string) { + if path == "" { + return + } + logMu.Lock() + defer logMu.Unlock() + logFile = path +} + +func Log(message string, data any) { + entry := map[string]any{ + "ts": time.Now().Format(time.RFC3339Nano), + "message": message, + } + if data != nil { + entry["data"] = data + } + payload, err := json.Marshal(entry) + if err != nil { + payload = []byte(fmt.Sprintf(`{"ts":"%s","message":"%s"}`, time.Now().Format(time.RFC3339Nano), message)) + } + + logMu.Lock() + defer logMu.Unlock() + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + _, _ = f.Write(append(payload, '\n')) +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..fa4d0be --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,39 @@ +package metrics + +import "sync/atomic" + +type Snapshot struct { + TrackedSessions uint64 `json:"tracked_sessions"` + PendingSessions uint64 `json:"pending_sessions"` + QueueDepth uint64 `json:"queue_depth"` +} + +type Metrics struct { + trackedSessions atomic.Uint64 + pendingSessions atomic.Uint64 + queueDepth atomic.Uint64 +} + +func New() *Metrics { + return &Metrics{} +} + +func (m *Metrics) SetTrackedSessions(v uint64) { + m.trackedSessions.Store(v) +} + +func (m *Metrics) SetPendingSessions(v uint64) { + m.pendingSessions.Store(v) +} + +func (m *Metrics) SetQueueDepth(v uint64) { + m.queueDepth.Store(v) +} + +func (m *Metrics) Snapshot() Snapshot { + return Snapshot{ + TrackedSessions: m.trackedSessions.Load(), + PendingSessions: m.pendingSessions.Load(), + QueueDepth: m.queueDepth.Load(), + } +} diff --git a/internal/process/process.go b/internal/process/process.go new file mode 100644 index 0000000..7715783 --- /dev/null +++ b/internal/process/process.go @@ -0,0 +1,107 @@ +package process + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +func SafeExec(command string) string { + cmd := exec.Command("sh", "-lc", command) + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func GetListeningPIDs(port int) []int { + if runtime.GOOS == "windows" { + return nil + } + out := SafeExec(fmt.Sprintf("lsof -nP -iTCP:%d -sTCP:LISTEN -t", port)) + if out == "" { + return nil + } + return parsePIDs(out) +} + +func IsProcessAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + return proc.Signal(syscall.Signal(0)) == nil +} + +func GetProcessCommand(pid int) string { + return SafeExec(fmt.Sprintf("ps -p %d -o command=", pid)) +} + +func GetProcessChildren(pid int) []int { + if runtime.GOOS == "windows" { + return nil + } + out := SafeExec(fmt.Sprintf("pgrep -P %d", pid)) + if out == "" { + return nil + } + return parsePIDs(out) +} + +func SafeKill(pid int, signal syscall.Signal) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + err = proc.Signal(signal) + if err == nil { + return true + } + if strings.Contains(err.Error(), "process already finished") || strings.Contains(err.Error(), "no such process") { + return true + } + return false +} + +func WaitForProcessExit(pid int, timeout time.Duration) bool { + if timeout <= 0 { + timeout = 2 * time.Second + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !IsProcessAlive(pid) { + return true + } + time.Sleep(100 * time.Millisecond) + } + return !IsProcessAlive(pid) +} + +func FindProcessIDs(pattern string) []int { + if runtime.GOOS == "windows" { + return nil + } + out := SafeExec(fmt.Sprintf("pgrep -f %q", pattern)) + if out == "" { + return nil + } + return parsePIDs(out) +} + +func parsePIDs(output string) []int { + parts := strings.Split(strings.TrimSpace(output), "\n") + pids := make([]int, 0, len(parts)) + for _, part := range parts { + p, err := strconv.Atoi(strings.TrimSpace(part)) + if err == nil { + pids = append(pids, p) + } + } + return pids +} diff --git a/internal/reaper/reaper.go b/internal/reaper/reaper.go new file mode 100644 index 0000000..397cb77 --- /dev/null +++ b/internal/reaper/reaper.go @@ -0,0 +1,200 @@ +package reaper + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "syscall" + "time" + + "github.com/AnganSamadder/opentmux/internal/config" + "github.com/AnganSamadder/opentmux/internal/logging" + proc "github.com/AnganSamadder/opentmux/internal/process" +) + +type candidate struct { + count int + firstSeen time.Time +} + +type Reaper struct { + serverURL string + cfg config.Config + ticker *time.Ticker + stop chan struct{} + mu sync.Mutex + cands map[int]candidate +} + +func New(serverURL string, cfg config.Config) *Reaper { + return &Reaper{ + serverURL: serverURL, + cfg: cfg, + stop: make(chan struct{}), + cands: make(map[int]candidate), + } +} + +func (r *Reaper) Start() { + if !r.cfg.ReaperEnabled || r.cfg.ReaperIntervalMs <= 0 { + return + } + if r.ticker != nil { + return + } + r.ticker = time.NewTicker(time.Duration(r.cfg.ReaperIntervalMs) * time.Millisecond) + go func() { + for { + select { + case <-r.ticker.C: + r.ScanOnce(context.Background()) + case <-r.stop: + return + } + } + }() +} + +func (r *Reaper) Stop() { + if r.ticker != nil { + r.ticker.Stop() + r.ticker = nil + } + select { + case <-r.stop: + default: + close(r.stop) + } +} + +func (r *Reaper) ScanOnce(ctx context.Context) { + processes := proc.FindProcessIDs("opencode attach") + if len(processes) == 0 { + r.mu.Lock() + r.cands = make(map[int]candidate) + r.mu.Unlock() + return + } + + active, ok := r.fetchActiveSessions(ctx) + if !ok { + logging.Log("[reaper] active session fetch failed", nil) + return + } + + now := time.Now() + present := make(map[int]struct{}, len(processes)) + + for _, pid := range processes { + present[pid] = struct{}{} + cmd := proc.GetProcessCommand(pid) + if cmd == "" || !strings.Contains(cmd, r.serverURL) { + continue + } + sid := extractSessionID(cmd) + if sid == "" || active[sid] { + r.mu.Lock() + delete(r.cands, pid) + r.mu.Unlock() + continue + } + + r.mu.Lock() + cand := r.cands[pid] + if cand.count == 0 { + cand = candidate{count: 1, firstSeen: now} + } else { + cand.count++ + } + r.cands[pid] = cand + shouldKill := cand.count >= r.cfg.ReaperMinZombieChecks && now.Sub(cand.firstSeen) >= time.Duration(r.cfg.ReaperGracePeriodMs)*time.Millisecond + r.mu.Unlock() + + if shouldKill { + proc.SafeKill(pid, syscall.SIGTERM) + if !proc.WaitForProcessExit(pid, 2*time.Second) { + proc.SafeKill(pid, syscall.SIGKILL) + } + r.mu.Lock() + delete(r.cands, pid) + r.mu.Unlock() + logging.Log("[reaper] reaped zombie", map[string]any{"pid": pid, "sessionId": sid}) + } + } + + r.mu.Lock() + for pid := range r.cands { + if _, ok := present[pid]; !ok { + delete(r.cands, pid) + } + } + r.mu.Unlock() +} + +func (r *Reaper) fetchActiveSessions(ctx context.Context) (map[string]bool, bool) { + url := strings.TrimRight(r.serverURL, "/") + "/session/status" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, false + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, false + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, false + } + + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, false + } + result := make(map[string]bool) + if data, ok := payload["data"].(map[string]any); ok { + for k := range data { + result[k] = true + } + return result, true + } + for k := range payload { + if strings.HasPrefix(k, "ses_") || strings.HasPrefix(k, "session_") { + result[k] = true + } + } + return result, true +} + +func extractSessionID(cmd string) string { + parts := strings.Fields(cmd) + for i := 0; i < len(parts); i++ { + if parts[i] == "--session" && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +func ReapAll(maxPorts int) { + if maxPorts <= 0 { + maxPorts = 10 + } + start := 4096 + end := 4096 + maxPorts + for port := start; port <= end; port++ { + pids := proc.GetListeningPIDs(port) + for _, pid := range pids { + cmd := proc.GetProcessCommand(pid) + if strings.Contains(cmd, "opencode") || strings.Contains(cmd, "node") || strings.Contains(cmd, "bun") { + proc.SafeKill(pid, syscall.SIGTERM) + if !proc.WaitForProcessExit(pid, 2*time.Second) { + proc.SafeKill(pid, syscall.SIGKILL) + } + fmt.Printf("Reaped server PID %d on port %d\n", pid, port) + } + } + } +} diff --git a/internal/sessionmanager/manager.go b/internal/sessionmanager/manager.go new file mode 100644 index 0000000..6cda8e1 --- /dev/null +++ b/internal/sessionmanager/manager.go @@ -0,0 +1,292 @@ +package sessionmanager + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "sync" + "time" + + "github.com/AnganSamadder/opentmux/internal/config" + "github.com/AnganSamadder/opentmux/internal/logging" + "github.com/AnganSamadder/opentmux/internal/metrics" + "github.com/AnganSamadder/opentmux/internal/reaper" + "github.com/AnganSamadder/opentmux/internal/spawnqueue" + "github.com/AnganSamadder/opentmux/internal/tmux" +) + +const ( + pollIntervalMs = 2000 + sessionTimeout = 10 * time.Minute + sessionMissingGraceMs = pollIntervalMs * 3 +) + +type SessionEvent struct { + Type string + ID string + ParentID string + Title string +} + +type trackedSession struct { + SessionID string + PaneID string + ParentID string + Title string + CreatedAt time.Time + LastSeenAt time.Time + MissingSince *time.Time +} + +type Manager struct { + mu sync.Mutex + cfg config.Config + serverURL string + enabled bool + sessions map[string]*trackedSession + pending map[string]struct{} + queue *spawnqueue.Queue + ticker *time.Ticker + done chan struct{} + layoutTimer *time.Timer + reaper *reaper.Reaper + metrics *metrics.Metrics +} + +func New(cfg config.Config, serverURL string, m *metrics.Metrics) *Manager { + if m == nil { + m = metrics.New() + } + mgr := &Manager{ + cfg: cfg, + serverURL: serverURL, + enabled: cfg.Enabled && tmux.IsInsideTmux(), + sessions: make(map[string]*trackedSession), + pending: make(map[string]struct{}), + done: make(chan struct{}), + metrics: m, + } + mgr.queue = spawnqueue.New(spawnqueue.Options{ + SpawnFn: func(ctx context.Context, req spawnqueue.SpawnRequest) spawnqueue.SpawnResult { + res := tmux.SpawnPane(req.SessionID, req.Title, cfg, serverURL) + return spawnqueue.SpawnResult{Success: res.Success, PaneID: res.PaneID} + }, + SpawnDelay: time.Duration(cfg.SpawnDelayMs) * time.Millisecond, + MaxRetries: cfg.MaxRetryAttempts, + OnQueueUpdate: func(pending int) { + mgr.metrics.SetQueueDepth(uint64(pending)) + }, + OnQueueDrained: func() { + mgr.scheduleLayout() + }, + }) + + mgr.reaper = reaper.New(serverURL, cfg) + if mgr.enabled { + mgr.reaper.Start() + } + + return mgr +} + +func (m *Manager) OnSessionCreated(ctx context.Context, event SessionEvent) bool { + if !m.enabled || event.Type != "session.created" || event.ID == "" || event.ParentID == "" { + return false + } + + m.mu.Lock() + if _, ok := m.sessions[event.ID]; ok { + m.mu.Unlock() + return false + } + if _, ok := m.pending[event.ID]; ok { + m.mu.Unlock() + return false + } + m.pending[event.ID] = struct{}{} + m.metrics.SetPendingSessions(uint64(len(m.pending))) + m.mu.Unlock() + + title := event.Title + if title == "" { + title = "Subagent" + } + + result := m.queue.Enqueue(ctx, event.ID, title) + + m.mu.Lock() + delete(m.pending, event.ID) + m.metrics.SetPendingSessions(uint64(len(m.pending))) + if result.Success && result.PaneID != "" { + now := time.Now() + m.sessions[event.ID] = &trackedSession{ + SessionID: event.ID, + PaneID: result.PaneID, + ParentID: event.ParentID, + Title: title, + CreatedAt: now, + LastSeenAt: now, + } + m.metrics.SetTrackedSessions(uint64(len(m.sessions))) + if m.ticker == nil { + m.ticker = time.NewTicker(pollIntervalMs * time.Millisecond) + go m.pollLoop() + } + } + m.mu.Unlock() + + return result.Success +} + +func (m *Manager) pollLoop() { + for { + select { + case <-m.ticker.C: + m.pollOnce(context.Background()) + case <-m.done: + return + } + } +} + +func (m *Manager) pollOnce(ctx context.Context) { + m.mu.Lock() + if len(m.sessions) == 0 { + m.mu.Unlock() + return + } + m.mu.Unlock() + + statuses, ok := m.fetchStatuses(ctx) + if !ok { + return + } + + now := time.Now() + toClose := make([]string, 0) + + m.mu.Lock() + for sessionID, tracked := range m.sessions { + statusType, hasStatus := statuses[sessionID] + isIdle := statusType == "idle" + if hasStatus { + tracked.LastSeenAt = now + tracked.MissingSince = nil + } else if tracked.MissingSince == nil { + t := now + tracked.MissingSince = &t + } + + missingTooLong := tracked.MissingSince != nil && now.Sub(*tracked.MissingSince) >= time.Duration(sessionMissingGraceMs)*time.Millisecond + isTimedOut := now.Sub(tracked.CreatedAt) >= sessionTimeout + if isIdle || missingTooLong || isTimedOut { + toClose = append(toClose, sessionID) + } + } + m.mu.Unlock() + + for _, sessionID := range toClose { + m.CloseSession(sessionID) + } +} + +func (m *Manager) fetchStatuses(ctx context.Context) (map[string]string, bool) { + statusURL := strings.TrimRight(m.serverURL, "/") + "/session/status" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) + if err != nil { + return nil, false + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, false + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, false + } + + var payload struct { + Data map[string]struct { + Type string `json:"type"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, false + } + + statuses := make(map[string]string, len(payload.Data)) + for id, status := range payload.Data { + statuses[id] = status.Type + } + return statuses, true +} + +func (m *Manager) CloseSession(sessionID string) { + m.mu.Lock() + tracked, ok := m.sessions[sessionID] + if !ok { + m.mu.Unlock() + return + } + delete(m.sessions, sessionID) + m.metrics.SetTrackedSessions(uint64(len(m.sessions))) + m.mu.Unlock() + + _ = tmux.ClosePane(tracked.PaneID, m.cfg) + + m.mu.Lock() + if len(m.sessions) == 0 && m.ticker != nil { + m.ticker.Stop() + m.ticker = nil + } + m.mu.Unlock() +} + +func (m *Manager) scheduleLayout() { + m.mu.Lock() + if m.layoutTimer != nil { + m.layoutTimer.Stop() + } + debounce := m.cfg.LayoutDebounceMs + if debounce <= 0 { + debounce = 150 + } + m.layoutTimer = time.AfterFunc(time.Duration(debounce)*time.Millisecond, func() { + _ = tmux.ApplyLayout(m.cfg) + }) + m.mu.Unlock() +} + +func (m *Manager) Cleanup(reason string) { + logging.Log("[session-manager] cleanup", map[string]any{"reason": reason}) + select { + case <-m.done: + default: + close(m.done) + } + if m.ticker != nil { + m.ticker.Stop() + } + if m.layoutTimer != nil { + m.layoutTimer.Stop() + } + m.queue.Shutdown() + m.reaper.Stop() + + m.mu.Lock() + ids := make([]string, 0, len(m.sessions)) + for id := range m.sessions { + ids = append(ids, id) + } + m.mu.Unlock() + + for _, sessionID := range ids { + m.CloseSession(sessionID) + } +} + +func (m *Manager) Snapshot() metrics.Snapshot { + return m.metrics.Snapshot() +} diff --git a/internal/spawnqueue/queue.go b/internal/spawnqueue/queue.go new file mode 100644 index 0000000..34342fa --- /dev/null +++ b/internal/spawnqueue/queue.go @@ -0,0 +1,278 @@ +package spawnqueue + +import ( + "context" + "math" + "sync" + "time" +) + +const ( + baseBackoffMs = 250 + defaultStaleThreshold = 30 * time.Second +) + +type SpawnRequest struct { + SessionID string + Title string + Timestamp int64 + RetryCount int +} + +type SpawnResult struct { + Success bool + PaneID string +} + +type SpawnFn func(context.Context, SpawnRequest) SpawnResult + +type Options struct { + SpawnFn SpawnFn + SpawnDelay time.Duration + MaxRetries int + StaleThreshold time.Duration + OnQueueUpdate func(int) + OnQueueDrained func() +} + +type queueItem struct { + sessionID string + title string + enqueuedAt time.Time + waiters []chan SpawnResult +} + +type Queue struct { + mu sync.Mutex + spawnFn SpawnFn + spawnDelay time.Duration + maxRetries int + staleThreshold time.Duration + onQueueUpdate func(int) + onQueueDrained func() + + items []*queueItem + pendingBySession map[string]*queueItem + inFlight *queueItem + isProcessing bool + isShutdown bool +} + +func New(opts Options) *Queue { + spawnDelay := opts.SpawnDelay + if spawnDelay <= 0 { + spawnDelay = 300 * time.Millisecond + } + staleThreshold := opts.StaleThreshold + if staleThreshold <= 0 { + staleThreshold = defaultStaleThreshold + } + maxRetries := opts.MaxRetries + if maxRetries < 0 { + maxRetries = 0 + } + + return &Queue{ + spawnFn: opts.SpawnFn, + spawnDelay: spawnDelay, + maxRetries: maxRetries, + staleThreshold: staleThreshold, + onQueueUpdate: opts.OnQueueUpdate, + onQueueDrained: opts.OnQueueDrained, + pendingBySession: make(map[string]*queueItem), + } +} + +func (q *Queue) Enqueue(ctx context.Context, sessionID, title string) SpawnResult { + resultCh := make(chan SpawnResult, 1) + + q.mu.Lock() + if q.isShutdown { + q.mu.Unlock() + return SpawnResult{Success: false} + } + + if existing, ok := q.pendingBySession[sessionID]; ok { + existing.waiters = append(existing.waiters, resultCh) + q.mu.Unlock() + select { + case res := <-resultCh: + return res + case <-ctx.Done(): + return SpawnResult{Success: false} + } + } + + item := &queueItem{ + sessionID: sessionID, + title: title, + enqueuedAt: time.Now(), + waiters: []chan SpawnResult{resultCh}, + } + q.items = append(q.items, item) + q.pendingBySession[sessionID] = item + pending := q.pendingCountLocked() + q.mu.Unlock() + + q.notifyUpdate(pending) + q.processAsync() + + select { + case res := <-resultCh: + return res + case <-ctx.Done(): + return SpawnResult{Success: false} + } +} + +func (q *Queue) PendingCount() int { + q.mu.Lock() + defer q.mu.Unlock() + return q.pendingCountLocked() +} + +func (q *Queue) Shutdown() { + q.mu.Lock() + if q.isShutdown { + q.mu.Unlock() + return + } + q.isShutdown = true + + toResolve := make([]*queueItem, 0, len(q.pendingBySession)) + for _, item := range q.pendingBySession { + toResolve = append(toResolve, item) + } + + q.items = nil + q.pendingBySession = make(map[string]*queueItem) + q.inFlight = nil + q.mu.Unlock() + + for _, item := range toResolve { + q.resolveItem(item, SpawnResult{Success: false}) + } + q.notifyUpdate(0) +} + +func (q *Queue) processAsync() { + q.mu.Lock() + if q.isProcessing || q.isShutdown { + q.mu.Unlock() + return + } + q.isProcessing = true + q.mu.Unlock() + + go q.processLoop() +} + +func (q *Queue) processLoop() { + defer func() { + q.mu.Lock() + q.isProcessing = false + empty := len(q.items) == 0 && q.inFlight == nil + q.mu.Unlock() + if empty && q.onQueueDrained != nil { + q.onQueueDrained() + } + }() + + for { + q.mu.Lock() + if q.isShutdown || len(q.items) == 0 { + pending := q.pendingCountLocked() + q.mu.Unlock() + q.notifyUpdate(pending) + return + } + + item := q.items[0] + q.items = q.items[1:] + q.inFlight = item + pending := q.pendingCountLocked() + q.mu.Unlock() + + q.notifyUpdate(pending) + if time.Since(item.enqueuedAt) > q.staleThreshold { + q.resolveItem(item, SpawnResult{Success: false}) + q.mu.Lock() + if q.inFlight == item { + q.inFlight = nil + } + delete(q.pendingBySession, item.sessionID) + q.mu.Unlock() + continue + } + + res := q.processItem(item) + q.resolveItem(item, res) + + q.mu.Lock() + if q.inFlight == item { + q.inFlight = nil + } + delete(q.pendingBySession, item.sessionID) + hasNext := len(q.items) > 0 + isShutdown := q.isShutdown + q.mu.Unlock() + + if !isShutdown && hasNext { + time.Sleep(q.spawnDelay) + } + } +} + +func (q *Queue) processItem(item *queueItem) SpawnResult { + result := SpawnResult{Success: false} + for attempt := 0; attempt <= q.maxRetries; attempt++ { + q.mu.Lock() + isShutdown := q.isShutdown + q.mu.Unlock() + if isShutdown { + return SpawnResult{Success: false} + } + if q.spawnFn == nil { + return SpawnResult{Success: false} + } + result = q.spawnFn(context.Background(), SpawnRequest{ + SessionID: item.sessionID, + Title: item.title, + Timestamp: item.enqueuedAt.UnixMilli(), + RetryCount: attempt, + }) + if result.Success { + return result + } + if attempt < q.maxRetries { + backoff := time.Duration(float64(baseBackoffMs)*math.Pow(2, float64(attempt))) * time.Millisecond + time.Sleep(backoff) + } + } + return result +} + +func (q *Queue) resolveItem(item *queueItem, result SpawnResult) { + q.mu.Lock() + waiters := item.waiters + item.waiters = nil + q.mu.Unlock() + for _, waiter := range waiters { + waiter <- result + close(waiter) + } +} + +func (q *Queue) pendingCountLocked() int { + count := len(q.items) + if q.inFlight != nil { + count++ + } + return count +} + +func (q *Queue) notifyUpdate(pending int) { + if q.onQueueUpdate != nil { + q.onQueueUpdate(pending) + } +} diff --git a/internal/spawnqueue/queue_bench_test.go b/internal/spawnqueue/queue_bench_test.go new file mode 100644 index 0000000..78fb92f --- /dev/null +++ b/internal/spawnqueue/queue_bench_test.go @@ -0,0 +1,24 @@ +package spawnqueue + +import ( + "context" + "strconv" + "testing" +) + +func BenchmarkQueueBurst100(b *testing.B) { + for i := 0; i < b.N; i++ { + q := New(Options{ + SpawnFn: func(context.Context, SpawnRequest) SpawnResult { + return SpawnResult{Success: true, PaneID: "%1"} + }, + SpawnDelay: 0, + MaxRetries: 0, + }) + + for n := 0; n < 100; n++ { + _ = q.Enqueue(context.Background(), "ses-"+strconv.Itoa(n), "task") + } + q.Shutdown() + } +} diff --git a/internal/spawnqueue/queue_test.go b/internal/spawnqueue/queue_test.go new file mode 100644 index 0000000..99db5d6 --- /dev/null +++ b/internal/spawnqueue/queue_test.go @@ -0,0 +1,213 @@ +package spawnqueue + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +func TestQueueProcessesSequentially(t *testing.T) { + releaseFirst := make(chan struct{}) + started := make(chan string, 2) + + q := New(Options{ + SpawnFn: func(_ context.Context, req SpawnRequest) SpawnResult { + started <- req.SessionID + if req.SessionID == "s1" { + <-releaseFirst + } + return SpawnResult{Success: true, PaneID: "%" + req.SessionID} + }, + SpawnDelay: 1 * time.Millisecond, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + result1 := make(chan SpawnResult, 1) + result2 := make(chan SpawnResult, 1) + go func() { result1 <- q.Enqueue(ctx, "s1", "Task 1") }() + + if got := <-started; got != "s1" { + t.Fatalf("expected first started session s1, got %s", got) + } + go func() { result2 <- q.Enqueue(ctx, "s2", "Task 2") }() + + select { + case got := <-started: + t.Fatalf("expected s2 to wait, but started early: %s", got) + case <-time.After(80 * time.Millisecond): + } + + close(releaseFirst) + if got := <-started; got != "s2" { + t.Fatalf("expected second started session s2, got %s", got) + } + + if !(<-result1).Success { + t.Fatal("expected first enqueue to succeed") + } + if !(<-result2).Success { + t.Fatal("expected second enqueue to succeed") + } + if got := q.PendingCount(); got != 0 { + t.Fatalf("expected pending=0, got %d", got) + } +} + +func TestQueueCoalescesDuplicateDuringInFlight(t *testing.T) { + release := make(chan struct{}) + var calls atomic.Int32 + started := make(chan struct{}, 1) + + q := New(Options{ + SpawnFn: func(_ context.Context, req SpawnRequest) SpawnResult { + calls.Add(1) + started <- struct{}{} + if req.SessionID == "s1" { + <-release + } + return SpawnResult{Success: true, PaneID: "%1"} + }, + SpawnDelay: 1 * time.Millisecond, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + r1 := make(chan SpawnResult, 1) + r2 := make(chan SpawnResult, 1) + go func() { r1 <- q.Enqueue(ctx, "s1", "Task") }() + <-started + go func() { r2 <- q.Enqueue(ctx, "s1", "Task duplicate") }() + + time.Sleep(40 * time.Millisecond) + if got := calls.Load(); got != 1 { + t.Fatalf("expected one spawn call for duplicate in-flight enqueue, got %d", got) + } + + close(release) + if !(<-r1).Success { + t.Fatal("expected first result success") + } + if !(<-r2).Success { + t.Fatal("expected duplicate result success") + } + if got := calls.Load(); got != 1 { + t.Fatalf("expected one spawn call total, got %d", got) + } +} + +func TestQueueRetriesAndPropagatesRetryCount(t *testing.T) { + counts := make([]int, 0, 3) + + q := New(Options{ + SpawnFn: func(_ context.Context, req SpawnRequest) SpawnResult { + counts = append(counts, req.RetryCount) + if len(counts) < 3 { + return SpawnResult{Success: false} + } + return SpawnResult{Success: true, PaneID: "%ok"} + }, + SpawnDelay: 1 * time.Millisecond, + MaxRetries: 2, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + res := q.Enqueue(ctx, "retry", "Retry") + if !res.Success { + t.Fatal("expected success after retries") + } + if len(counts) != 3 { + t.Fatalf("expected 3 attempts, got %d", len(counts)) + } + for i, v := range counts { + if v != i { + t.Fatalf("expected retryCount[%d]=%d, got %d", i, i, v) + } + } +} + +func TestQueueShutdownResolvesPendingAndRejectsFutureEnqueue(t *testing.T) { + release := make(chan struct{}) + started := make(chan struct{}, 1) + q := New(Options{ + SpawnFn: func(_ context.Context, req SpawnRequest) SpawnResult { + if req.SessionID == "s1" { + started <- struct{}{} + <-release + } + return SpawnResult{Success: true, PaneID: "%" + req.SessionID} + }, + SpawnDelay: 1 * time.Millisecond, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + r1 := make(chan SpawnResult, 1) + rDup := make(chan SpawnResult, 1) + r2 := make(chan SpawnResult, 1) + go func() { r1 <- q.Enqueue(ctx, "s1", "Task 1") }() + <-started + go func() { rDup <- q.Enqueue(ctx, "s1", "Task 1 dup") }() + go func() { r2 <- q.Enqueue(ctx, "s2", "Task 2") }() + + time.Sleep(20 * time.Millisecond) + q.Shutdown() + close(release) + + for _, ch := range []chan SpawnResult{r1, rDup, r2} { + select { + case res := <-ch: + if res.Success { + t.Fatal("expected shutdown to resolve pending requests as failed") + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for shutdown-resolved result") + } + } + + if res := q.Enqueue(ctx, "late", "Late"); res.Success { + t.Fatal("expected enqueue after shutdown to fail") + } +} + +func TestQueueSkipsStaleItems(t *testing.T) { + block := make(chan struct{}) + var calls atomic.Int32 + + q := New(Options{ + SpawnFn: func(_ context.Context, req SpawnRequest) SpawnResult { + calls.Add(1) + if req.SessionID == "s1" { + <-block + } + return SpawnResult{Success: true, PaneID: "%" + req.SessionID} + }, + SpawnDelay: 1 * time.Millisecond, + StaleThreshold: 20 * time.Millisecond, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + r1 := make(chan SpawnResult, 1) + r2 := make(chan SpawnResult, 1) + go func() { r1 <- q.Enqueue(ctx, "s1", "one") }() + time.Sleep(10 * time.Millisecond) + go func() { r2 <- q.Enqueue(ctx, "s2", "two") }() + time.Sleep(70 * time.Millisecond) + close(block) + + if !(<-r1).Success { + t.Fatal("expected first request to succeed") + } + if (<-r2).Success { + t.Fatal("expected stale second request to fail") + } + if got := calls.Load(); got != 1 { + t.Fatalf("expected stale item to skip spawn call, got %d calls", got) + } +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go new file mode 100644 index 0000000..cd3750f --- /dev/null +++ b/internal/tmux/tmux.go @@ -0,0 +1,193 @@ +package tmux + +import ( + "context" + "fmt" + "net/http" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "time" + + "github.com/AnganSamadder/opentmux/internal/config" + "github.com/AnganSamadder/opentmux/internal/logging" + proc "github.com/AnganSamadder/opentmux/internal/process" +) + +type SpawnResult struct { + Success bool + PaneID string +} + +var ( + tmuxPathOnce sync.Once + tmuxPath string +) + +func IsInsideTmux() bool { + return os.Getenv("TMUX") != "" +} + +func findTmuxPath() string { + cmd := exec.Command("sh", "-lc", "which tmux") + out, err := cmd.Output() + if err != nil { + return "" + } + path := strings.TrimSpace(string(out)) + if path == "" { + return "" + } + verify := exec.Command(path, "-V") + if err := verify.Run(); err != nil { + return "" + } + return path +} + +func GetTmuxPath() string { + tmuxPathOnce.Do(func() { + tmuxPath = findTmuxPath() + }) + return tmuxPath +} + +func runCommand(args ...string) (string, string, error) { + if len(args) == 0 { + return "", "", fmt.Errorf("empty command") + } + cmd := exec.Command(args[0], args[1:]...) + out, err := cmd.Output() + if err == nil { + return strings.TrimSpace(string(out)), "", nil + } + if ee, ok := err.(*exec.ExitError); ok { + return strings.TrimSpace(string(out)), strings.TrimSpace(string(ee.Stderr)), err + } + return strings.TrimSpace(string(out)), "", err +} + +func IsServerRunning(serverURL string) bool { + healthURL := strings.TrimRight(serverURL, "/") + "/health" + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + if err != nil { + return false + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode >= 200 && resp.StatusCode < 300 +} + +func SpawnPane(sessionID string, title string, cfg config.Config, serverURL string) SpawnResult { + if !cfg.Enabled || !IsInsideTmux() { + return SpawnResult{Success: false} + } + if !IsServerRunning(serverURL) { + logging.Log("[tmux] server unavailable", map[string]any{"serverUrl": serverURL}) + return SpawnResult{Success: false} + } + + tmuxPath := GetTmuxPath() + if tmuxPath == "" { + return SpawnResult{Success: false} + } + + opencodeCmd := fmt.Sprintf("opencode attach %s --session %s", serverURL, sessionID) + stdout, stderr, err := runCommand(tmuxPath, "split-window", "-h", "-d", "-P", "-F", "#{pane_id}", opencodeCmd) + if err != nil { + logging.Log("[tmux] split-window failed", map[string]any{"error": err.Error(), "stderr": stderr}) + return SpawnResult{Success: false} + } + + paneID := strings.TrimSpace(stdout) + if paneID == "" { + return SpawnResult{Success: false} + } + + _, _, _ = runCommand(tmuxPath, "select-pane", "-t", paneID, "-T", truncateTitle(title)) + _ = ApplyLayout(cfg) + return SpawnResult{Success: true, PaneID: paneID} +} + +func ClosePane(paneID string, cfg config.Config) bool { + if paneID == "" { + return false + } + tmuxPath := GetTmuxPath() + if tmuxPath == "" { + return false + } + + stdout, _, err := runCommand(tmuxPath, "list-panes", "-t", paneID, "-F", "#{pane_pid}") + if err == nil { + if shellPID := parsePID(stdout); shellPID > 0 { + children := proc.GetProcessChildren(shellPID) + for _, childPID := range children { + cmd := proc.GetProcessCommand(childPID) + if strings.Contains(cmd, "opencode") { + proc.SafeKill(childPID, syscall.SIGTERM) + if !proc.WaitForProcessExit(childPID, 2*time.Second) { + proc.SafeKill(childPID, syscall.SIGKILL) + } + } + } + } + } + + _, stderr, killErr := runCommand(tmuxPath, "kill-pane", "-t", paneID) + if killErr != nil { + logging.Log("[tmux] kill-pane failed", map[string]any{"paneId": paneID, "error": killErr.Error(), "stderr": stderr}) + return false + } + _ = ApplyLayout(cfg) + return true +} + +func ApplyLayout(cfg config.Config) error { + tmuxPath := GetTmuxPath() + if tmuxPath == "" { + return fmt.Errorf("tmux not found") + } + layout := cfg.Layout + if layout == "" { + layout = "main-vertical" + } + _, _, err := runCommand(tmuxPath, "select-layout", layout) + if err != nil { + _, _, _ = runCommand(tmuxPath, "select-layout", "main-vertical") + return err + } + if layout == "main-horizontal" || layout == "main-vertical" { + sizeOption := "main-pane-width" + if layout == "main-horizontal" { + sizeOption = "main-pane-height" + } + _, _, _ = runCommand(tmuxPath, "set-window-option", sizeOption, fmt.Sprintf("%d%%", cfg.MainPaneSize)) + } + return nil +} + +func truncateTitle(title string) string { + if len(title) <= 30 { + return title + } + return title[:30] +} + +func parsePID(raw string) int { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + var pid int + _, _ = fmt.Sscanf(raw, "%d", &pid) + return pid +} diff --git a/package.json b/package.json index f7ee9e8..a472f71 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,12 @@ "dist" ], "scripts": { - "build": "tsup", + "build": "tsup && bun run build:go", + "build:go": "bun run scripts/build-go-runtime.ts", "dev": "tsup --watch", "typecheck": "tsc --noEmit", + "proto:generate": "buf dep update && buf generate", + "bench:burst": "bun run scripts/bench/run-burst.ts", "prepublishOnly": "bun run build", "postinstall": "test -f dist/scripts/install.js && node dist/scripts/install.js || echo 'Skipping postinstall setup (dist not found)'" }, diff --git a/proto/opentmux/v1/opentmux.proto b/proto/opentmux/v1/opentmux.proto new file mode 100644 index 0000000..a15111c --- /dev/null +++ b/proto/opentmux/v1/opentmux.proto @@ -0,0 +1,76 @@ +syntax = "proto3"; + +package opentmux.v1; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/AnganSamadder/opentmux/gen/go/opentmux/v1;opentmuxv1"; + +message Config { + bool enabled = 1; + uint32 port = 2; + string layout = 3; + uint32 main_pane_size = 4 [(buf.validate.field).uint32 = {gte: 20, lte: 80}]; + bool auto_close = 5; + uint32 spawn_delay_ms = 6; + uint32 max_retry_attempts = 7; + uint32 layout_debounce_ms = 8; + uint32 max_agents_per_column = 9; + bool reaper_enabled = 10; + uint32 reaper_interval_ms = 11; + uint32 reaper_min_zombie_checks = 12; + uint32 reaper_grace_period_ms = 13; + bool reaper_auto_self_destruct = 14; + uint32 reaper_self_destruct_timeout_ms = 15; + bool rotate_port = 16; + uint32 max_ports = 17; +} + +message InitRequest { + string directory = 1; + string server_url = 2; + Config config = 3; +} + +message InitResponse { + bool enabled = 1; + string message = 2; +} + +message SessionCreatedInfo { + string id = 1; + string parent_id = 2; + string title = 3; +} + +message SessionCreatedRequest { + string type = 1; + SessionCreatedInfo info = 2; +} + +message SessionCreatedResponse { + bool accepted = 1; +} + +message ShutdownRequest { + string reason = 1; +} + +message ShutdownResponse { + bool ok = 1; +} + +message StatsRequest {} + +message StatsResponse { + uint64 tracked_sessions = 1; + uint64 pending_sessions = 2; + uint64 queue_depth = 3; +} + +service OpentmuxControl { + rpc Init(InitRequest) returns (InitResponse); + rpc OnSessionCreated(SessionCreatedRequest) returns (SessionCreatedResponse); + rpc Shutdown(ShutdownRequest) returns (ShutdownResponse); + rpc Stats(StatsRequest) returns (StatsResponse); +} diff --git a/scripts/bench/run-burst.ts b/scripts/bench/run-burst.ts new file mode 100644 index 0000000..ddfe8bf --- /dev/null +++ b/scripts/bench/run-burst.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env bun + +import { SpawnQueue } from '../../src/spawn-queue'; + +async function runTsBenchmark(iterations: number): Promise { + const started = performance.now(); + for (let i = 0; i < iterations; i++) { + const queue = new SpawnQueue({ + spawnFn: async () => ({ success: true, paneId: '%1' }), + spawnDelayMs: 0, + maxRetries: 0, + logFn: () => {}, + }); + + const tasks = Array.from({ length: 100 }, (_, idx) => + queue.enqueue({ sessionId: `ses-${i}-${idx}`, title: 'task' }), + ); + await Promise.all(tasks); + queue.shutdown(); + } + return performance.now() - started; +} + +async function run(): Promise { + const iterations = Number(process.env.BENCH_ITERATIONS ?? '50'); + const tsMs = await runTsBenchmark(iterations); + console.log(`TS benchmark (${iterations}x100): ${tsMs.toFixed(2)} ms`); + + const goBench = Bun.spawnSync(['go', 'test', './internal/spawnqueue', '-bench=BenchmarkQueueBurst100', '-benchmem', '-run=^$'], { + stdout: 'pipe', + stderr: 'pipe', + }); + + if (goBench.exitCode !== 0) { + console.error(goBench.stderr.toString()); + process.exit(goBench.exitCode); + } + + console.log(goBench.stdout.toString()); +} + +void run(); diff --git a/scripts/build-go-runtime.ts b/scripts/build-go-runtime.ts new file mode 100644 index 0000000..6ea8e8c --- /dev/null +++ b/scripts/build-go-runtime.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env bun + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const cwd = process.cwd(); +const suffix = (osName: string) => (osName === 'windows' ? '.exe' : ''); + +interface Target { + os: string; + arch: string; +} + +const defaultTargets: Target[] = [ + { os: os.platform() === 'darwin' ? 'darwin' : os.platform() === 'win32' ? 'windows' : 'linux', arch: os.arch() === 'x64' ? 'amd64' : os.arch() === 'arm64' ? 'arm64' : os.arch() }, +]; + +const releaseTargets: Target[] = [ + { os: 'darwin', arch: 'arm64' }, + { os: 'darwin', arch: 'amd64' }, + { os: 'linux', arch: 'amd64' }, + { os: 'linux', arch: 'arm64' }, + { os: 'windows', arch: 'amd64' }, +]; + +const targets = process.env.OPENTMUX_GO_RELEASE === '1' ? releaseTargets : defaultTargets; +const binaries = ['opentmux', 'opentmuxd', 'opentmuxctl'] as const; + +function run(cmd: string[], env: Record): void { + const proc = Bun.spawnSync(cmd, { + cwd, + stdout: 'inherit', + stderr: 'inherit', + env: { ...process.env, ...env }, + }); + if (proc.exitCode !== 0) { + throw new Error(`command failed: ${cmd.join(' ')}`); + } +} + +function build(): void { + const runtimeDir = path.join(cwd, 'dist', 'runtime'); + fs.rmSync(runtimeDir, { recursive: true, force: true }); + + for (const target of targets) { + const outDir = path.join(runtimeDir, `${target.os}-${target.arch}`); + fs.mkdirSync(outDir, { recursive: true }); + + for (const bin of binaries) { + const outPath = path.join(outDir, `${bin}${suffix(target.os)}`); + run( + ['go', 'build', '-trimpath', '-ldflags=-s -w', '-o', outPath, `./cmd/${bin}`], + { + GOOS: target.os, + GOARCH: target.arch, + CGO_ENABLED: '0', + }, + ); + } + } + + const localBinDir = path.join(cwd, 'bin'); + fs.mkdirSync(localBinDir, { recursive: true }); + const localTag = `${defaultTargets[0].os}-${defaultTargets[0].arch}`; + const localRuntimeDir = path.join(runtimeDir, localTag); + + for (const bin of binaries) { + const from = path.join(localRuntimeDir, `${bin}${suffix(defaultTargets[0].os)}`); + const to = path.join(localBinDir, `${bin}${suffix(defaultTargets[0].os)}`); + fs.copyFileSync(from, to); + fs.chmodSync(to, 0o755); + } +} + +build(); diff --git a/src/bin/opentmux-legacy.ts b/src/bin/opentmux-legacy.ts new file mode 100644 index 0000000..3aa6177 --- /dev/null +++ b/src/bin/opentmux-legacy.ts @@ -0,0 +1,557 @@ +#!/usr/bin/env node + +import { spawn, execSync } from 'node:child_process'; +import { createServer } from 'node:net'; +import { env, platform, exit, argv } from 'node:process'; +import { existsSync, appendFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { ZombieReaper } from '../zombie-reaper'; +import { loadConfig } from '../utils/config-loader'; +import { + safeExec, + getListeningPids, + isProcessAlive, + getProcessCommand, + safeKill, + waitForProcessExit, + getProcessStartTime +} from '../utils/process'; + +// Load config +const config = loadConfig(); +const OPENCODE_PORT_START = config.port || parseInt(env.OPENCODE_PORT || '4096', 10); +const OPENCODE_PORT_MAX = OPENCODE_PORT_START + (config.max_ports || 10); +const LOG_FILE = '/tmp/opentmux.log'; +const HEALTH_TIMEOUT_MS = 1000; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function log(...args: unknown[]): void { + const timestamp = new Date().toISOString(); + const message = `[${timestamp}] ${args.join(' ')}\n`; + try { + appendFileSync(LOG_FILE, message); + } catch {} +} + +function spawnPluginUpdater(): void { + if (env.OPENCODE_TMUX_DISABLE_UPDATES === '1') return; + + const updaterPath = join(__dirname, '../scripts/update-plugins.js'); + if (!existsSync(updaterPath)) return; + + try { + const child = spawn( + process.execPath, + [updaterPath], + { + stdio: 'ignore', + detached: true, + env: { + ...process.env, + OPENCODE_TMUX_UPDATE: '1' + } + } + ); + child.unref(); + } catch (error) {} +} + +function findOpencodeBin(): string | null { + try { + const cmd = platform === 'win32' ? 'where opencode' : 'which -a opencode'; + const output = execSync(cmd, { encoding: 'utf-8' }).trim().split('\n'); + + const currentScript = argv[1]; + + for (const bin of output) { + const normalizedBin = bin.trim(); + if (normalizedBin.includes('opentmux') || normalizedBin === currentScript) continue; + if (normalizedBin) return normalizedBin; + } + } catch (e) {} + + const commonPaths = [ + join(homedir(), '.opencode', 'bin', platform === 'win32' ? 'opencode.exe' : 'opencode'), + join(homedir(), 'AppData', 'Local', 'opencode', 'bin', 'opencode.exe'), + '/usr/local/bin/opencode', + '/usr/bin/opencode' + ]; + + for (const p of commonPaths) { + if (existsSync(p)) return p; + } + + return null; +} + +function checkPort(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.listen(port, '127.0.0.1'); + server.on('listening', () => { + server.close(); + resolve(true); + }); + server.on('error', () => { + resolve(false); + }); + }); +} + +function getTmuxPanePids(): Set { + if (!hasTmux()) return new Set(); + + const output = safeExec("tmux list-panes -a -F '#{pane_pid}'"); + if (!output) return new Set(); + + const pids = output + .split('\n') + .map((value) => Number.parseInt(value.trim(), 10)) + .filter((value) => Number.isFinite(value)); + + return new Set(pids); +} + +async function isOpencodeHealthy(port: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS); + const healthUrl = `http://127.0.0.1:${port}/health`; + + try { + const response = await fetch(healthUrl, { signal: controller.signal }).catch( + () => null, + ); + return response?.ok ?? false; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +function getProcessStat(pid: number): string | null { + const output = safeExec(`ps -p ${pid} -o stat=`); + return output && output.length > 0 ? output.trim() : null; +} + +function getProcessTty(pid: number): string | null { + const output = safeExec(`ps -p ${pid} -o tty=`); + return output && output.length > 0 ? output.trim() : null; +} + +function getTtyProcessIds(tty: string): number[] { + const output = safeExec(`ps -t ${tty} -o pid=`); + if (!output) return []; + return output + .split('\n') + .map((value) => Number.parseInt(value.trim(), 10)) + .filter((value) => Number.isFinite(value)); +} + +function hasOtherTtyProcesses(tty: string | null, pid: number): boolean { + if (!tty || tty === '?' || tty === '??') return false; + const ttyPids = getTtyProcessIds(tty); + return ttyPids.some((ttyPid) => ttyPid !== pid); +} + +function getParentPid(pid: number): number | null { + const output = safeExec(`ps -p ${pid} -o ppid=`); + if (!output) return null; + const value = Number.parseInt(output.trim(), 10); + return Number.isFinite(value) ? value : null; +} + +function isDescendantOf(pid: number, ancestors: Set): boolean { + let current = pid; + const visited = new Set(); + + while (current > 1 && !visited.has(current)) { + if (ancestors.has(current)) return true; + visited.add(current); + + const parent = getParentPid(current); + if (!parent || parent <= 1) return false; + current = parent; + } + + return false; +} + +function isForegroundProcess(pid: number): boolean { + const stat = safeExec(`ps -p ${pid} -o stat=`); + if (!stat) return false; + return stat.includes('+'); +} + +async function getOpencodeSessionCount(port: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS); + const statusUrl = `http://127.0.0.1:${port}/session/status`; + + try { + const response = await fetch(statusUrl, { signal: controller.signal }).catch( + () => null, + ); + if (!response?.ok) return null; + + const payload = (await response.json().catch(() => null)) as unknown; + if (!payload || typeof payload !== 'object') return null; + + const maybeData = (payload as { data?: unknown }).data; + if (maybeData && typeof maybeData === 'object' && !Array.isArray(maybeData)) { + return Object.keys(maybeData as Record).length; + } + + if (!Array.isArray(payload)) { + return Object.keys(payload as Record).length; + } + + return payload.length; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +async function tryReclaimPort( + port: number, + tmuxPanePids: Set, +): Promise { + if (platform === 'win32') return false; + + const healthy = await isOpencodeHealthy(port); + if (healthy) return false; + + const pids = getListeningPids(port); + + log( + 'Port scan:', + port.toString(), + 'healthy', + String(healthy), + 'pids', + pids.length > 0 ? pids.join(',') : 'none', + ); + + if (pids.length === 0) { + return false; + } + + let attemptedKill = false; + for (const pid of pids) { + const command = getProcessCommand(pid); + const tty = getProcessTty(pid); + const stat = getProcessStat(pid); + const hasTtyPeers = hasOtherTtyProcesses(tty, pid); + + const inTmux = tmuxPanePids.size > 0 && isDescendantOf(pid, tmuxPanePids); + log( + 'Port process:', + port.toString(), + 'pid', + pid.toString(), + 'tty', + tty ?? 'unknown', + 'stat', + stat ?? 'unknown', + 'tmux', + String(inTmux), + 'ttyPeers', + String(hasTtyPeers), + 'command', + command ?? 'unknown', + ); + + if (command && command.includes('opencode')) { + if (inTmux) { + log('Port owned by tmux process, skipping:', port.toString(), pid.toString()); + continue; + } + + if (hasTtyPeers) { + log('Port owned by active tty process, skipping:', port.toString(), pid.toString()); + continue; + } + + if (isForegroundProcess(pid)) { + log('Port owned by potentially busy foreground process, skipping:', port.toString(), pid.toString()); + continue; + } + } + + log('Attempting to stop stale or non-opencode process:', port.toString(), pid.toString()); + attemptedKill = true; + try { + process.kill(pid, 'SIGTERM'); + } catch {} + } + + if (!attemptedKill) return false; + + await new Promise((resolve) => setTimeout(resolve, 700)); + + for (const pid of pids) { + if (isProcessAlive(pid)) { + log('Process still alive, sending SIGKILL:', port.toString(), pid.toString()); + try { + process.kill(pid, 'SIGKILL'); + } catch {} + } + } + + await new Promise((resolve) => setTimeout(resolve, 400)); + return checkPort(port); +} + +async function findAvailablePort(): Promise { + let tmuxPanePids: Set | null = null; + for (let port = OPENCODE_PORT_START; port <= OPENCODE_PORT_MAX; port++) { + if (await checkPort(port)) return port; + + if (!tmuxPanePids) { + tmuxPanePids = getTmuxPanePids(); + } + + const reclaimed = await tryReclaimPort(port, tmuxPanePids); + if (reclaimed && (await checkPort(port))) return port; + } + return null; +} + +function hasTmux(): boolean { + try { + execSync('tmux -V', { stdio: 'ignore' }); + return true; + } catch (e) { + return false; + } +} + +async function main() { + + // Check if running as a script (node script.js) or a compiled binary + // In script mode: argv[0]=node, argv[1]=script, argv[2]=arg1 -> slice(2) + // In binary mode: argv[0]=binary, argv[1]=arg1 -> slice(1) + // Use regex to securely match only actual node/bun executables + const isRuntime = /\/?(node|bun)(\.exe)?$/i.test(argv[0]); + const isScriptFile = argv[1] ? /\.(js|ts)$/.test(argv[1]) || argv[1].includes('opentmux') : false; + // Both runtime AND script file must be present for script mode + const args = (isRuntime && isScriptFile) ? argv.slice(2) : argv.slice(1); + + // Check for opentmux-specific flags first + if (args.includes('--reap') || args.includes('-reap')) { + await ZombieReaper.reapAll(); + exit(0); + } + + // Define known CLI commands that should NOT trigger a tmux session + // These are commands that either: + // 1. Run quickly and exit (CLI tools) + // 2. Are server/daemon processes that manage their own lifecycle + // 3. Are help/version flags + const NON_TUI_COMMANDS = [ + // Core CLI commands + 'auth', 'config', 'plugins', 'update', 'upgrade', 'completion', 'stats', + 'run', 'exec', 'doctor', 'debug', 'clean', 'uninstall', + + // Agent/Session management + 'agent', 'session', 'export', 'import', 'github', 'pr', + + // Server commands (usually run in fg, don't need tmux wrapper) + 'serve', 'web', 'acp', 'mcp', 'models', + + // Flags + '--version', '-v', '--help', '-h' + ]; + + const isCliCommand = args.length > 0 && NON_TUI_COMMANDS.includes(args[0]); + const isInteractiveMode = args.length === 0; + + // For CLI commands and interactive mode (no args), bypass tmux + if (isCliCommand || isInteractiveMode) { + const opencodeBin = findOpencodeBin(); + if (!opencodeBin) { + console.error( + 'Error: Could not find "opencode" binary in PATH or common locations.', + ); + exit(1); + } + + const bypassArgs = [...args]; + const hasPrintLogs = args.includes('--print-logs'); + if (!hasPrintLogs && !args.some((arg) => arg.startsWith('--log-level'))) { + bypassArgs.push('--log-level', 'ERROR'); + } + + const child = spawn(opencodeBin, bypassArgs, { + stdio: ['inherit', 'inherit', 'pipe'], + env: process.env, + }); + + child.stderr?.on('data', (data) => { + const lines = data.toString().split('\n'); + const filtered = lines.filter( + (line: string) => !/^INFO\s+.*service=models\.dev.*refreshing/.test(line), + ); + process.stderr.write(filtered.join('\n')); + }); + + child.on('close', (code) => { + exit(code ?? 0); + }); + return; + } + + log('=== OpenCode Tmux Wrapper Started ==='); + log('Process argv:', JSON.stringify(argv)); + log('Current directory:', process.cwd()); + + const opencodeBin = findOpencodeBin(); + log('Found opencode binary:', opencodeBin); + + if (!opencodeBin) { + console.error('Error: Could not find "opencode" binary in PATH or common locations.'); + log('ERROR: opencode binary not found'); + exit(1); + } + + spawnPluginUpdater(); + + let port = await findAvailablePort(); + log('Found available port:', port); + + if (!port) { + if (config.rotate_port) { + log('Port rotation enabled. Finding oldest session to kill...'); + let oldestPid: number | null = null; + let oldestTime = Date.now(); + let targetPort = -1; + + for (let p = OPENCODE_PORT_START; p <= OPENCODE_PORT_MAX; p++) { + const pids = getListeningPids(p); + for (const pid of pids) { + const cmd = getProcessCommand(pid); + if (cmd && (cmd.includes('opencode') || cmd.includes('node') || cmd.includes('bun'))) { + const startTime = getProcessStartTime(pid); + if (startTime && startTime < oldestTime) { + oldestTime = startTime; + oldestPid = pid; + targetPort = p; + } + } + } + } + + if (oldestPid && targetPort !== -1) { + log('Rotating port:', targetPort, 'Killing oldest PID:', oldestPid); + console.log(`♻️ Port rotation: Killing oldest session (PID ${oldestPid}) on port ${targetPort} to make room...`); + safeKill(oldestPid, 'SIGTERM'); + await waitForProcessExit(oldestPid, 2000); + if (isProcessAlive(oldestPid)) { + safeKill(oldestPid, 'SIGKILL'); + await waitForProcessExit(oldestPid, 1000); + } + + // Re-check the port to confirm it's free + if (await checkPort(targetPort)) { + port = targetPort; + log('Port reclaimed successfully:', port); + } else { + console.error(`⚠️ Failed to reclaim port ${targetPort} even after killing PID ${oldestPid}.`); + exit(1); + } + } else { + console.error('Error: Could not find any valid OpenCode sessions to rotate.'); + exit(1); + } + } else { + console.error(`Error: No available ports found in range ${OPENCODE_PORT_START}-${OPENCODE_PORT_MAX}.`); + console.error('Tip: Run "opentmux -reap" to clean up stuck sessions.'); + console.error(' Or enable "rotate_port": true in config to automatically recycle oldest sessions.'); + log('ERROR: No available ports'); + exit(1); + } + } + + const env2 = { ...process.env }; + env2.OPENCODE_PORT = port.toString(); + + log('User args:', JSON.stringify(args)); + + const childArgs = ['--port', port.toString(), ...args]; + log('Final childArgs:', JSON.stringify(childArgs)); + + const inTmux = !!env2.TMUX; + const tmuxAvailable = hasTmux(); + + log('In tmux?', inTmux); + log('Tmux available?', tmuxAvailable); + + if (inTmux || !tmuxAvailable) { + log('Running directly (in tmux or no tmux available)'); + + const child = spawn(opencodeBin, childArgs, { stdio: 'inherit', env: env2 }); + + child.on('error', (err) => { + log('ERROR spawning child:', err.message); + }); + + child.on('close', (code) => { + log('Child exited with code:', code); + exit(code ?? 0); + }); + + process.on('SIGINT', () => child.kill('SIGINT')); + process.on('SIGTERM', () => child.kill('SIGTERM')); + + } else { + console.log("🚀 Launching tmux session..."); + log('Launching tmux session'); + + const escapedBin = opencodeBin.includes(' ') ? `'${opencodeBin}'` : opencodeBin; + const escapedArgs = childArgs.map(arg => { + if (arg.includes(' ') || arg.includes('"') || arg.includes("'")) { + return `'${arg.replace(/'/g, "'\\''")}'`; + } + return arg; + }); + + const shellCommand = `${escapedBin} ${escapedArgs.join(' ')} || { echo "Exit code: $?"; echo "Press Enter to close..."; read; }`; + + log('Shell command for tmux:', shellCommand); + + const tmuxArgs = [ + 'new-session', + shellCommand + ]; + + log('Tmux args:', JSON.stringify(tmuxArgs)); + + const child = spawn('tmux', tmuxArgs, { stdio: 'inherit', env: env2 }); + + child.on('error', (err) => { + log('ERROR spawning tmux:', err.message); + }); + + child.on('close', (code) => { + log('Tmux exited with code:', code); + exit(code ?? 0); + }); + } +} + +main().catch(err => { + // Handle AbortError gracefully (user cancelled) + if (err.name === 'AbortError' || err.code === 20) { + exit(0); + } + + log('FATAL ERROR:', err.message, err.stack); + console.error(err); + exit(1); +}); diff --git a/src/bin/opentmux.ts b/src/bin/opentmux.ts index 3aa6177..08e541b 100644 --- a/src/bin/opentmux.ts +++ b/src/bin/opentmux.ts @@ -1,557 +1,29 @@ #!/usr/bin/env node -import { spawn, execSync } from 'node:child_process'; -import { createServer } from 'node:net'; -import { env, platform, exit, argv } from 'node:process'; -import { existsSync, appendFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { homedir } from 'node:os'; -import { fileURLToPath } from 'node:url'; -import { ZombieReaper } from '../zombie-reaper'; -import { loadConfig } from '../utils/config-loader'; -import { - safeExec, - getListeningPids, - isProcessAlive, - getProcessCommand, - safeKill, - waitForProcessExit, - getProcessStartTime -} from '../utils/process'; +import { spawn } from 'node:child_process'; +import { resolveGoBinary } from '../utils/go-runtime'; -// Load config -const config = loadConfig(); -const OPENCODE_PORT_START = config.port || parseInt(env.OPENCODE_PORT || '4096', 10); -const OPENCODE_PORT_MAX = OPENCODE_PORT_START + (config.max_ports || 10); -const LOG_FILE = '/tmp/opentmux.log'; -const HEALTH_TIMEOUT_MS = 1000; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -function log(...args: unknown[]): void { - const timestamp = new Date().toISOString(); - const message = `[${timestamp}] ${args.join(' ')}\n`; - try { - appendFileSync(LOG_FILE, message); - } catch {} -} - -function spawnPluginUpdater(): void { - if (env.OPENCODE_TMUX_DISABLE_UPDATES === '1') return; - - const updaterPath = join(__dirname, '../scripts/update-plugins.js'); - if (!existsSync(updaterPath)) return; - - try { - const child = spawn( - process.execPath, - [updaterPath], - { - stdio: 'ignore', - detached: true, - env: { - ...process.env, - OPENCODE_TMUX_UPDATE: '1' - } - } - ); - child.unref(); - } catch (error) {} -} - -function findOpencodeBin(): string | null { - try { - const cmd = platform === 'win32' ? 'where opencode' : 'which -a opencode'; - const output = execSync(cmd, { encoding: 'utf-8' }).trim().split('\n'); - - const currentScript = argv[1]; - - for (const bin of output) { - const normalizedBin = bin.trim(); - if (normalizedBin.includes('opentmux') || normalizedBin === currentScript) continue; - if (normalizedBin) return normalizedBin; - } - } catch (e) {} - - const commonPaths = [ - join(homedir(), '.opencode', 'bin', platform === 'win32' ? 'opencode.exe' : 'opencode'), - join(homedir(), 'AppData', 'Local', 'opencode', 'bin', 'opencode.exe'), - '/usr/local/bin/opencode', - '/usr/bin/opencode' - ]; - - for (const p of commonPaths) { - if (existsSync(p)) return p; - } - - return null; -} - -function checkPort(port: number): Promise { - return new Promise((resolve) => { - const server = createServer(); - server.listen(port, '127.0.0.1'); - server.on('listening', () => { - server.close(); - resolve(true); - }); - server.on('error', () => { - resolve(false); - }); - }); -} - -function getTmuxPanePids(): Set { - if (!hasTmux()) return new Set(); - - const output = safeExec("tmux list-panes -a -F '#{pane_pid}'"); - if (!output) return new Set(); - - const pids = output - .split('\n') - .map((value) => Number.parseInt(value.trim(), 10)) - .filter((value) => Number.isFinite(value)); - - return new Set(pids); -} - -async function isOpencodeHealthy(port: number): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS); - const healthUrl = `http://127.0.0.1:${port}/health`; - - try { - const response = await fetch(healthUrl, { signal: controller.signal }).catch( - () => null, - ); - return response?.ok ?? false; - } catch { - return false; - } finally { - clearTimeout(timeout); - } -} - -function getProcessStat(pid: number): string | null { - const output = safeExec(`ps -p ${pid} -o stat=`); - return output && output.length > 0 ? output.trim() : null; -} - -function getProcessTty(pid: number): string | null { - const output = safeExec(`ps -p ${pid} -o tty=`); - return output && output.length > 0 ? output.trim() : null; -} - -function getTtyProcessIds(tty: string): number[] { - const output = safeExec(`ps -t ${tty} -o pid=`); - if (!output) return []; - return output - .split('\n') - .map((value) => Number.parseInt(value.trim(), 10)) - .filter((value) => Number.isFinite(value)); -} - -function hasOtherTtyProcesses(tty: string | null, pid: number): boolean { - if (!tty || tty === '?' || tty === '??') return false; - const ttyPids = getTtyProcessIds(tty); - return ttyPids.some((ttyPid) => ttyPid !== pid); -} - -function getParentPid(pid: number): number | null { - const output = safeExec(`ps -p ${pid} -o ppid=`); - if (!output) return null; - const value = Number.parseInt(output.trim(), 10); - return Number.isFinite(value) ? value : null; -} - -function isDescendantOf(pid: number, ancestors: Set): boolean { - let current = pid; - const visited = new Set(); - - while (current > 1 && !visited.has(current)) { - if (ancestors.has(current)) return true; - visited.add(current); - - const parent = getParentPid(current); - if (!parent || parent <= 1) return false; - current = parent; - } - - return false; -} - -function isForegroundProcess(pid: number): boolean { - const stat = safeExec(`ps -p ${pid} -o stat=`); - if (!stat) return false; - return stat.includes('+'); -} - -async function getOpencodeSessionCount(port: number): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS); - const statusUrl = `http://127.0.0.1:${port}/session/status`; - - try { - const response = await fetch(statusUrl, { signal: controller.signal }).catch( - () => null, - ); - if (!response?.ok) return null; - - const payload = (await response.json().catch(() => null)) as unknown; - if (!payload || typeof payload !== 'object') return null; - - const maybeData = (payload as { data?: unknown }).data; - if (maybeData && typeof maybeData === 'object' && !Array.isArray(maybeData)) { - return Object.keys(maybeData as Record).length; - } - - if (!Array.isArray(payload)) { - return Object.keys(payload as Record).length; - } - - return payload.length; - } catch { - return null; - } finally { - clearTimeout(timeout); - } -} - -async function tryReclaimPort( - port: number, - tmuxPanePids: Set, -): Promise { - if (platform === 'win32') return false; - - const healthy = await isOpencodeHealthy(port); - if (healthy) return false; - - const pids = getListeningPids(port); - - log( - 'Port scan:', - port.toString(), - 'healthy', - String(healthy), - 'pids', - pids.length > 0 ? pids.join(',') : 'none', - ); - - if (pids.length === 0) { - return false; - } - - let attemptedKill = false; - for (const pid of pids) { - const command = getProcessCommand(pid); - const tty = getProcessTty(pid); - const stat = getProcessStat(pid); - const hasTtyPeers = hasOtherTtyProcesses(tty, pid); - - const inTmux = tmuxPanePids.size > 0 && isDescendantOf(pid, tmuxPanePids); - log( - 'Port process:', - port.toString(), - 'pid', - pid.toString(), - 'tty', - tty ?? 'unknown', - 'stat', - stat ?? 'unknown', - 'tmux', - String(inTmux), - 'ttyPeers', - String(hasTtyPeers), - 'command', - command ?? 'unknown', - ); - - if (command && command.includes('opencode')) { - if (inTmux) { - log('Port owned by tmux process, skipping:', port.toString(), pid.toString()); - continue; - } - - if (hasTtyPeers) { - log('Port owned by active tty process, skipping:', port.toString(), pid.toString()); - continue; - } - - if (isForegroundProcess(pid)) { - log('Port owned by potentially busy foreground process, skipping:', port.toString(), pid.toString()); - continue; - } - } - - log('Attempting to stop stale or non-opencode process:', port.toString(), pid.toString()); - attemptedKill = true; - try { - process.kill(pid, 'SIGTERM'); - } catch {} - } - - if (!attemptedKill) return false; - - await new Promise((resolve) => setTimeout(resolve, 700)); - - for (const pid of pids) { - if (isProcessAlive(pid)) { - log('Process still alive, sending SIGKILL:', port.toString(), pid.toString()); - try { - process.kill(pid, 'SIGKILL'); - } catch {} - } - } - - await new Promise((resolve) => setTimeout(resolve, 400)); - return checkPort(port); -} - -async function findAvailablePort(): Promise { - let tmuxPanePids: Set | null = null; - for (let port = OPENCODE_PORT_START; port <= OPENCODE_PORT_MAX; port++) { - if (await checkPort(port)) return port; - - if (!tmuxPanePids) { - tmuxPanePids = getTmuxPanePids(); - } - - const reclaimed = await tryReclaimPort(port, tmuxPanePids); - if (reclaimed && (await checkPort(port))) return port; - } - return null; -} - -function hasTmux(): boolean { - try { - execSync('tmux -V', { stdio: 'ignore' }); - return true; - } catch (e) { - return false; - } -} - -async function main() { - - // Check if running as a script (node script.js) or a compiled binary - // In script mode: argv[0]=node, argv[1]=script, argv[2]=arg1 -> slice(2) - // In binary mode: argv[0]=binary, argv[1]=arg1 -> slice(1) - // Use regex to securely match only actual node/bun executables - const isRuntime = /\/?(node|bun)(\.exe)?$/i.test(argv[0]); - const isScriptFile = argv[1] ? /\.(js|ts)$/.test(argv[1]) || argv[1].includes('opentmux') : false; - // Both runtime AND script file must be present for script mode - const args = (isRuntime && isScriptFile) ? argv.slice(2) : argv.slice(1); - - // Check for opentmux-specific flags first - if (args.includes('--reap') || args.includes('-reap')) { - await ZombieReaper.reapAll(); - exit(0); - } - - // Define known CLI commands that should NOT trigger a tmux session - // These are commands that either: - // 1. Run quickly and exit (CLI tools) - // 2. Are server/daemon processes that manage their own lifecycle - // 3. Are help/version flags - const NON_TUI_COMMANDS = [ - // Core CLI commands - 'auth', 'config', 'plugins', 'update', 'upgrade', 'completion', 'stats', - 'run', 'exec', 'doctor', 'debug', 'clean', 'uninstall', - - // Agent/Session management - 'agent', 'session', 'export', 'import', 'github', 'pr', - - // Server commands (usually run in fg, don't need tmux wrapper) - 'serve', 'web', 'acp', 'mcp', 'models', - - // Flags - '--version', '-v', '--help', '-h' - ]; - - const isCliCommand = args.length > 0 && NON_TUI_COMMANDS.includes(args[0]); - const isInteractiveMode = args.length === 0; - - // For CLI commands and interactive mode (no args), bypass tmux - if (isCliCommand || isInteractiveMode) { - const opencodeBin = findOpencodeBin(); - if (!opencodeBin) { - console.error( - 'Error: Could not find "opencode" binary in PATH or common locations.', - ); - exit(1); - } - - const bypassArgs = [...args]; - const hasPrintLogs = args.includes('--print-logs'); - if (!hasPrintLogs && !args.some((arg) => arg.startsWith('--log-level'))) { - bypassArgs.push('--log-level', 'ERROR'); - } - - const child = spawn(opencodeBin, bypassArgs, { - stdio: ['inherit', 'inherit', 'pipe'], - env: process.env, - }); - - child.stderr?.on('data', (data) => { - const lines = data.toString().split('\n'); - const filtered = lines.filter( - (line: string) => !/^INFO\s+.*service=models\.dev.*refreshing/.test(line), - ); - process.stderr.write(filtered.join('\n')); - }); - - child.on('close', (code) => { - exit(code ?? 0); - }); +async function main(): Promise { + const goCli = resolveGoBinary('opentmux'); + if (!goCli) { + await import('./opentmux-legacy'); return; } - log('=== OpenCode Tmux Wrapper Started ==='); - log('Process argv:', JSON.stringify(argv)); - log('Current directory:', process.cwd()); - - const opencodeBin = findOpencodeBin(); - log('Found opencode binary:', opencodeBin); - - if (!opencodeBin) { - console.error('Error: Could not find "opencode" binary in PATH or common locations.'); - log('ERROR: opencode binary not found'); - exit(1); - } - - spawnPluginUpdater(); - - let port = await findAvailablePort(); - log('Found available port:', port); - - if (!port) { - if (config.rotate_port) { - log('Port rotation enabled. Finding oldest session to kill...'); - let oldestPid: number | null = null; - let oldestTime = Date.now(); - let targetPort = -1; - - for (let p = OPENCODE_PORT_START; p <= OPENCODE_PORT_MAX; p++) { - const pids = getListeningPids(p); - for (const pid of pids) { - const cmd = getProcessCommand(pid); - if (cmd && (cmd.includes('opencode') || cmd.includes('node') || cmd.includes('bun'))) { - const startTime = getProcessStartTime(pid); - if (startTime && startTime < oldestTime) { - oldestTime = startTime; - oldestPid = pid; - targetPort = p; - } - } - } - } - - if (oldestPid && targetPort !== -1) { - log('Rotating port:', targetPort, 'Killing oldest PID:', oldestPid); - console.log(`♻️ Port rotation: Killing oldest session (PID ${oldestPid}) on port ${targetPort} to make room...`); - safeKill(oldestPid, 'SIGTERM'); - await waitForProcessExit(oldestPid, 2000); - if (isProcessAlive(oldestPid)) { - safeKill(oldestPid, 'SIGKILL'); - await waitForProcessExit(oldestPid, 1000); - } - - // Re-check the port to confirm it's free - if (await checkPort(targetPort)) { - port = targetPort; - log('Port reclaimed successfully:', port); - } else { - console.error(`⚠️ Failed to reclaim port ${targetPort} even after killing PID ${oldestPid}.`); - exit(1); - } - } else { - console.error('Error: Could not find any valid OpenCode sessions to rotate.'); - exit(1); - } - } else { - console.error(`Error: No available ports found in range ${OPENCODE_PORT_START}-${OPENCODE_PORT_MAX}.`); - console.error('Tip: Run "opentmux -reap" to clean up stuck sessions.'); - console.error(' Or enable "rotate_port": true in config to automatically recycle oldest sessions.'); - log('ERROR: No available ports'); - exit(1); - } - } - - const env2 = { ...process.env }; - env2.OPENCODE_PORT = port.toString(); - - log('User args:', JSON.stringify(args)); - - const childArgs = ['--port', port.toString(), ...args]; - log('Final childArgs:', JSON.stringify(childArgs)); - - const inTmux = !!env2.TMUX; - const tmuxAvailable = hasTmux(); - - log('In tmux?', inTmux); - log('Tmux available?', tmuxAvailable); - - if (inTmux || !tmuxAvailable) { - log('Running directly (in tmux or no tmux available)'); - - const child = spawn(opencodeBin, childArgs, { stdio: 'inherit', env: env2 }); - - child.on('error', (err) => { - log('ERROR spawning child:', err.message); - }); - - child.on('close', (code) => { - log('Child exited with code:', code); - exit(code ?? 0); - }); - - process.on('SIGINT', () => child.kill('SIGINT')); - process.on('SIGTERM', () => child.kill('SIGTERM')); - - } else { - console.log("🚀 Launching tmux session..."); - log('Launching tmux session'); - - const escapedBin = opencodeBin.includes(' ') ? `'${opencodeBin}'` : opencodeBin; - const escapedArgs = childArgs.map(arg => { - if (arg.includes(' ') || arg.includes('"') || arg.includes("'")) { - return `'${arg.replace(/'/g, "'\\''")}'`; - } - return arg; - }); - - const shellCommand = `${escapedBin} ${escapedArgs.join(' ')} || { echo "Exit code: $?"; echo "Press Enter to close..."; read; }`; - - log('Shell command for tmux:', shellCommand); + const args = process.argv.slice(2); + const child = spawn(goCli, args, { + stdio: 'inherit', + env: process.env, + }); - const tmuxArgs = [ - 'new-session', - shellCommand - ]; - - log('Tmux args:', JSON.stringify(tmuxArgs)); + child.on('close', (code) => { + process.exit(code ?? 0); + }); - const child = spawn('tmux', tmuxArgs, { stdio: 'inherit', env: env2 }); - - child.on('error', (err) => { - log('ERROR spawning tmux:', err.message); - }); - - child.on('close', (code) => { - log('Tmux exited with code:', code); - exit(code ?? 0); - }); - } + child.on('error', (err) => { + console.error(err); + process.exit(1); + }); } -main().catch(err => { - // Handle AbortError gracefully (user cancelled) - if (err.name === 'AbortError' || err.code === 20) { - exit(0); - } - - log('FATAL ERROR:', err.message, err.stack); - console.error(err); - exit(1); -}); +void main(); diff --git a/src/index.ts b/src/index.ts index c35567e..258fdbe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ -import type { Plugin } from './types'; -import { type TmuxConfig } from './config'; -import { TmuxSessionManager } from './tmux-session-manager'; -import { log, startTmuxCheck } from './utils'; +import type { ChildProcess } from 'node:child_process'; +import type { Plugin, PluginOutput } from './types'; import { loadConfig } from './utils/config-loader'; +import { log } from './utils'; +import { defaultSocketPath, execGoBinary, resolveGoBinary, spawnGoBinary } from './utils/go-runtime'; function detectServerUrl(): string { if (process.env.OPENCODE_PORT) { @@ -13,6 +13,77 @@ function detectServerUrl(): string { } let isInitialized = false; +let goDaemonProcess: ChildProcess | null = null; +let goSocketPath: string | null = null; +let usingGoCore = false; +let fallbackOutputPromise: Promise | null = null; + +async function getLegacyOutput(ctx: Parameters[0]): Promise { + if (!fallbackOutputPromise) { + fallbackOutputPromise = import('./legacy-plugin').then((mod) => mod.default(ctx)); + } + return fallbackOutputPromise; +} + +async function initGoCore(ctx: Parameters[0], serverUrl: string): Promise { + const daemon = resolveGoBinary('opentmuxd'); + const ctl = resolveGoBinary('opentmuxctl'); + + if (!daemon || !ctl) { + log('[plugin-go-shim] go runtime missing, falling back to TS', { daemon, ctl }); + return false; + } + + goSocketPath = process.env.OPENTMUXD_SOCKET_PATH ?? defaultSocketPath(); + + goDaemonProcess = spawnGoBinary(daemon, ['--socket', goSocketPath], { + detached: false, + stdio: 'ignore', + }); + + const started = await execGoBinary(ctl, [ + 'init', + '--socket', + goSocketPath, + '--directory', + ctx.directory, + '--server-url', + serverUrl, + ]); + + if (!started.success) { + log('[plugin-go-shim] go init failed, falling back to TS', { + code: started.code, + stderr: started.stderr, + error: started.error, + }); + goDaemonProcess?.kill(); + goDaemonProcess = null; + goSocketPath = null; + return false; + } + + const cleanup = async (reason: string) => { + if (!goSocketPath) return; + await execGoBinary(ctl, ['shutdown', '--socket', goSocketPath, '--reason', reason]); + goDaemonProcess?.kill(); + goDaemonProcess = null; + goSocketPath = null; + }; + + process.once('SIGINT', () => { + void cleanup('SIGINT'); + }); + process.once('SIGTERM', () => { + void cleanup('SIGTERM'); + }); + process.once('beforeExit', () => { + void cleanup('beforeExit'); + }); + + usingGoCore = true; + return true; +} const OpencodeAgentTmux: Plugin = async (ctx) => { if (isInitialized) { @@ -27,51 +98,59 @@ const OpencodeAgentTmux: Plugin = async (ctx) => { isInitialized = true; const config = loadConfig(ctx.directory); - - const tmuxConfig: TmuxConfig = { - enabled: config.enabled, - layout: config.layout, - main_pane_size: config.main_pane_size, - spawn_delay_ms: config.spawn_delay_ms, - max_retry_attempts: config.max_retry_attempts, - layout_debounce_ms: config.layout_debounce_ms, - max_agents_per_column: config.max_agents_per_column, - reaper_enabled: config.reaper_enabled, - reaper_interval_ms: config.reaper_interval_ms, - reaper_min_zombie_checks: config.reaper_min_zombie_checks, - reaper_grace_period_ms: config.reaper_grace_period_ms, - reaper_auto_self_destruct: config.reaper_auto_self_destruct, - reaper_self_destruct_timeout_ms: config.reaper_self_destruct_timeout_ms, - rotate_port: config.rotate_port, - max_ports: config.max_ports, - }; - const serverUrl = ctx.serverUrl?.toString() || detectServerUrl(); - log('[plugin] initialized', { - tmuxConfig, + log('[plugin-go-shim] initialization', { directory: ctx.directory, serverUrl, + enabled: config.enabled, }); - if (tmuxConfig.enabled) { - startTmuxCheck(); + const goOk = await initGoCore(ctx, serverUrl); + if (!goOk) { + return getLegacyOutput(ctx); } - const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig, serverUrl); - return { name: 'opentmux', - event: async (input) => { - await tmuxSessionManager.onSessionCreated( - input.event as { - type: string; - properties?: { - info?: { id?: string; parentID?: string; title?: string }; - }; - }, - ); + if (!goSocketPath) { + return; + } + const ctl = resolveGoBinary('opentmuxctl'); + if (!ctl) { + return; + } + + const event = input.event as { + type: string; + properties?: { + info?: { id?: string; parentID?: string; title?: string }; + }; + }; + + const info = event.properties?.info; + if (!usingGoCore) { + const legacy = await getLegacyOutput(ctx); + if (legacy.event) { + await legacy.event(input); + } + return; + } + + await execGoBinary(ctl, [ + 'session-created', + '--socket', + goSocketPath, + '--type', + event.type, + '--id', + info?.id ?? '', + '--parent-id', + info?.parentID ?? '', + '--title', + info?.title ?? 'Subagent', + ]); }, }; }; diff --git a/src/legacy-plugin.ts b/src/legacy-plugin.ts new file mode 100644 index 0000000..c35567e --- /dev/null +++ b/src/legacy-plugin.ts @@ -0,0 +1,81 @@ +import type { Plugin } from './types'; +import { type TmuxConfig } from './config'; +import { TmuxSessionManager } from './tmux-session-manager'; +import { log, startTmuxCheck } from './utils'; +import { loadConfig } from './utils/config-loader'; + +function detectServerUrl(): string { + if (process.env.OPENCODE_PORT) { + return `http://localhost:${process.env.OPENCODE_PORT}`; + } + + return 'http://localhost:4096'; +} + +let isInitialized = false; + +const OpencodeAgentTmux: Plugin = async (ctx) => { + if (isInitialized) { + log('[plugin] duplicate initialization detected, skipping', { + directory: ctx.directory, + }); + return { + name: 'opentmux', + event: async () => {}, + }; + } + isInitialized = true; + + const config = loadConfig(ctx.directory); + + const tmuxConfig: TmuxConfig = { + enabled: config.enabled, + layout: config.layout, + main_pane_size: config.main_pane_size, + spawn_delay_ms: config.spawn_delay_ms, + max_retry_attempts: config.max_retry_attempts, + layout_debounce_ms: config.layout_debounce_ms, + max_agents_per_column: config.max_agents_per_column, + reaper_enabled: config.reaper_enabled, + reaper_interval_ms: config.reaper_interval_ms, + reaper_min_zombie_checks: config.reaper_min_zombie_checks, + reaper_grace_period_ms: config.reaper_grace_period_ms, + reaper_auto_self_destruct: config.reaper_auto_self_destruct, + reaper_self_destruct_timeout_ms: config.reaper_self_destruct_timeout_ms, + rotate_port: config.rotate_port, + max_ports: config.max_ports, + }; + + const serverUrl = ctx.serverUrl?.toString() || detectServerUrl(); + + log('[plugin] initialized', { + tmuxConfig, + directory: ctx.directory, + serverUrl, + }); + + if (tmuxConfig.enabled) { + startTmuxCheck(); + } + + const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig, serverUrl); + + return { + name: 'opentmux', + + event: async (input) => { + await tmuxSessionManager.onSessionCreated( + input.event as { + type: string; + properties?: { + info?: { id?: string; parentID?: string; title?: string }; + }; + }, + ); + }, + }; +}; + +export default OpencodeAgentTmux; + +export type { PluginConfig, TmuxConfig, TmuxLayout } from './config'; diff --git a/src/utils/go-runtime.ts b/src/utils/go-runtime.ts new file mode 100644 index 0000000..44e6a24 --- /dev/null +++ b/src/utils/go-runtime.ts @@ -0,0 +1,112 @@ +import { execFile, spawn, type ChildProcess } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const EXEC_TIMEOUT_MS = 15_000; + +type GoBinaryName = 'opentmux' | 'opentmuxd' | 'opentmuxctl'; + +function binarySuffix(): string { + return process.platform === 'win32' ? '.exe' : ''; +} + +function runtimeTag(): string { + const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : process.arch; + const platform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : process.platform; + return `${platform}-${arch}`; +} + +function existsAndExecutable(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function moduleRoot(): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, '..', '..'); +} + +export function resolveGoBinary(name: GoBinaryName): string | null { + const suffix = binarySuffix(); + const explicit = process.env[`OPENTMUX_GO_${name.toUpperCase()}_BIN`]; + if (explicit && existsAndExecutable(explicit)) { + return explicit; + } + + const binDir = process.env.OPENTMUX_GO_BIN_DIR; + if (binDir) { + const candidate = path.join(binDir, `${name}${suffix}`); + if (existsAndExecutable(candidate)) { + return candidate; + } + } + + const root = moduleRoot(); + const localBuild = path.join(root, 'bin', `${name}${suffix}`); + if (existsAndExecutable(localBuild)) { + return localBuild; + } + + const runtimeDirs = [ + path.join(root, 'dist', 'runtime', runtimeTag()), + path.join(root, 'runtime', runtimeTag()), + ]; + for (const runtimeDir of runtimeDirs) { + const runtimeBuild = path.join(runtimeDir, `${name}${suffix}`); + if (existsAndExecutable(runtimeBuild)) { + return runtimeBuild; + } + } + + return null; +} + +export interface ExecResult { + success: boolean; + code: number; + stdout: string; + stderr: string; + error?: string; +} + +export function execGoBinary(binaryPath: string, args: string[]): Promise { + return new Promise((resolve) => { + execFile(binaryPath, args, { timeout: EXEC_TIMEOUT_MS }, (error, stdout, stderr) => { + if (error) { + const code = (error as NodeJS.ErrnoException & { code?: number }).code; + resolve({ + success: false, + code: typeof code === 'number' ? code : 1, + stdout: stdout?.toString() ?? '', + stderr: stderr?.toString() ?? '', + error: String(error), + }); + return; + } + resolve({ + success: true, + code: 0, + stdout: stdout?.toString() ?? '', + stderr: stderr?.toString() ?? '', + }); + }); + }); +} + +export function spawnGoBinary(binaryPath: string, args: string[], options?: { detached?: boolean; stdio?: 'inherit' | 'ignore' }): ChildProcess { + return spawn(binaryPath, args, { + detached: options?.detached ?? false, + stdio: options?.stdio ?? 'ignore', + env: process.env, + }); +} + +export function defaultSocketPath(): string { + return path.join(os.tmpdir(), `opentmuxd-${process.pid}.sock`); +}