951 lines
27 KiB
Go
951 lines
27 KiB
Go
// Package zellij implements ports.Runtime using Zellij sessions.
|
|
package zellij
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/agentlaunch"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
|
)
|
|
|
|
const (
|
|
defaultTimeout = 5 * time.Second
|
|
defaultWindowsTimeout = 30 * time.Second
|
|
defaultZellijTerm = "xterm-256color"
|
|
defaultZellijColor = "truecolor"
|
|
minMajor = 0
|
|
minMinor = 44
|
|
minPatch = 3
|
|
)
|
|
|
|
var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
|
var paneIDPattern = regexp.MustCompile(`^terminal_\d+$`)
|
|
|
|
var getenv = os.Getenv
|
|
var lookPath = exec.LookPath
|
|
var fileExists = func(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
// Options configures a zellij Runtime; every field has a sensible default
|
|
// (see New), so the zero value is usable.
|
|
type Options struct {
|
|
Binary string
|
|
Timeout time.Duration
|
|
Shell string
|
|
SocketDir string
|
|
ConfigDir string
|
|
ChunkSize int
|
|
LauncherBinary string
|
|
}
|
|
|
|
// Runtime runs agent sessions inside zellij sessions, driving them via the
|
|
// zellij CLI. It implements ports.Runtime.
|
|
type Runtime struct {
|
|
binary string
|
|
timeout time.Duration
|
|
shell string
|
|
socketDir string
|
|
configDir string
|
|
chunkSize int
|
|
launcher string
|
|
runner runner
|
|
}
|
|
|
|
var _ ports.Runtime = (*Runtime)(nil)
|
|
|
|
// DefaultSocketDir returns a short, stable ZELLIJ_SOCKET_DIR for AO's daemon.
|
|
// zellij's own default lives under $TMPDIR (long on macOS), which leaves almost
|
|
// none of the ~103-byte unix-socket-path budget for the session name — a long
|
|
// session id then fails with "session name must be less than 0 characters". A
|
|
// short dir restores ample budget. Empty on Windows, where zellij is not used.
|
|
// Pure: callers that run zellij should MkdirAll the result.
|
|
func DefaultSocketDir() string {
|
|
if runtime.GOOS == "windows" {
|
|
return ""
|
|
}
|
|
return "/tmp/ao-zellij-" + strconv.Itoa(os.Getuid())
|
|
}
|
|
|
|
type runner interface {
|
|
Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error)
|
|
Start(env []string, name string, args ...string) error
|
|
}
|
|
|
|
type execRunner struct{}
|
|
|
|
func (execRunner) Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Env = zellijCommandEnv(os.Environ(), env)
|
|
hideWindow(cmd)
|
|
return cmd.CombinedOutput()
|
|
}
|
|
|
|
func (execRunner) Start(env []string, name string, args ...string) error {
|
|
return startBackgroundProcess(zellijCommandEnv(os.Environ(), env), name, args...)
|
|
}
|
|
|
|
// New builds a zellij Runtime, filling unset Options with defaults: binary
|
|
// "zellij", shell from $SHELL (else /bin/sh, or powershell.exe on Windows), and
|
|
// the default timeout and output chunk size.
|
|
func New(opts Options) *Runtime {
|
|
binary := opts.Binary
|
|
if binary == "" {
|
|
binary = defaultBinary()
|
|
}
|
|
timeout := opts.Timeout
|
|
if timeout == 0 {
|
|
timeout = defaultCommandTimeout()
|
|
}
|
|
shellPath := opts.Shell
|
|
if shellPath == "" {
|
|
shellPath = os.Getenv("SHELL")
|
|
}
|
|
if shellPath == "" {
|
|
if runtime.GOOS == "windows" {
|
|
shellPath = "powershell.exe"
|
|
} else {
|
|
shellPath = "/bin/sh"
|
|
}
|
|
}
|
|
chunkSize := opts.ChunkSize
|
|
if chunkSize <= 0 {
|
|
chunkSize = defaultChunkBytes
|
|
}
|
|
launcher := opts.LauncherBinary
|
|
if launcher == "" {
|
|
launcher = defaultLauncherBinary()
|
|
}
|
|
return &Runtime{binary: binary, timeout: timeout, shell: shellPath, socketDir: opts.SocketDir, configDir: opts.ConfigDir, chunkSize: chunkSize, launcher: launcher, runner: execRunner{}}
|
|
}
|
|
|
|
// defaultLauncherBinary returns the path used by the zellij Windows codepath
|
|
// to invoke the `ao launch` trampoline. On Windows the agent's argv is
|
|
// persisted to a temp spec file (see agentlaunch); zellij then runs this
|
|
// binary with `launch` and it execs the real agent. Falls back to plain "ao"
|
|
// if the daemon binary path cannot be resolved (PATH lookup at runtime).
|
|
func defaultLauncherBinary() string {
|
|
path, err := os.Executable()
|
|
if err == nil && isLauncherBinary(path) {
|
|
return path
|
|
}
|
|
return "ao"
|
|
}
|
|
|
|
func isLauncherBinary(path string) bool {
|
|
name := strings.ToLower(filepath.Base(path))
|
|
if runtime.GOOS == "windows" {
|
|
name = strings.TrimSuffix(name, ".exe")
|
|
}
|
|
return name == "ao"
|
|
}
|
|
|
|
func defaultCommandTimeout() time.Duration {
|
|
if runtime.GOOS == "windows" {
|
|
return defaultWindowsTimeout
|
|
}
|
|
return defaultTimeout
|
|
}
|
|
|
|
func defaultBinary() string {
|
|
names := []string{"zellij"}
|
|
if runtime.GOOS == "windows" {
|
|
names = []string{"zellij.exe", "zellij"}
|
|
}
|
|
for _, name := range names {
|
|
if path, err := lookPath(name); err == nil && path != "" {
|
|
return path
|
|
}
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
for _, candidate := range windowsZellijCandidates() {
|
|
if fileExists(candidate) {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
return "zellij"
|
|
}
|
|
|
|
func windowsZellijCandidates() []string {
|
|
candidates := []string{}
|
|
if localAppData := getenv("LOCALAPPDATA"); localAppData != "" {
|
|
candidates = append(candidates, filepath.Join(localAppData, "Programs", "zellij", "zellij.exe"))
|
|
}
|
|
for _, key := range []string{"ProgramFiles", "ProgramFiles(x86)"} {
|
|
if dir := getenv(key); dir != "" {
|
|
candidates = append(candidates,
|
|
filepath.Join(dir, "zellij", "zellij.exe"),
|
|
filepath.Join(dir, "Zellij", "zellij.exe"),
|
|
)
|
|
}
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
// Create starts a new zellij session in the workspace, running the agent's
|
|
// launch command, and returns a handle to it.
|
|
func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) {
|
|
id, err := zellijSessionName(cfg.SessionID)
|
|
if err != nil {
|
|
return ports.RuntimeHandle{}, err
|
|
}
|
|
if cfg.WorkspacePath == "" {
|
|
return ports.RuntimeHandle{}, errors.New("zellij runtime: workspace path is required")
|
|
}
|
|
if len(cfg.Argv) == 0 {
|
|
return ports.RuntimeHandle{}, errors.New("zellij runtime: launch command is required")
|
|
}
|
|
if err := validateEnvKeys(cfg.Env); err != nil {
|
|
return ports.RuntimeHandle{}, err
|
|
}
|
|
if err := r.ensureSupportedVersion(ctx); err != nil {
|
|
return ports.RuntimeHandle{}, err
|
|
}
|
|
// Zellij keeps exited sessions in a resurrection cache. A previous partial
|
|
// spawn can therefore make `attach --create-background` fail with "Session
|
|
// already exists" even though AO has no usable runtime handle. Clear any
|
|
// same-name runtime state before creating the new AO-owned session.
|
|
if err := r.Destroy(ctx, ports.RuntimeHandle{ID: id}); err != nil {
|
|
return ports.RuntimeHandle{}, err
|
|
}
|
|
|
|
layoutPath, launchEnv, cleanupLaunchSpec, err := r.writeLayout(cfg)
|
|
if err != nil {
|
|
return ports.RuntimeHandle{}, err
|
|
}
|
|
defer func() { _ = os.Remove(layoutPath) }()
|
|
cleanupOnFailure := true
|
|
defer func() {
|
|
if cleanupOnFailure && cleanupLaunchSpec != nil {
|
|
cleanupLaunchSpec()
|
|
}
|
|
}()
|
|
|
|
if err := r.createSession(ctx, id, layoutPath, launchEnv); err != nil {
|
|
return ports.RuntimeHandle{}, fmt.Errorf("zellij runtime: create session %s: %w", id, err)
|
|
}
|
|
paneID, err := r.findAgentPane(ctx, id)
|
|
if err != nil {
|
|
_ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id})
|
|
return ports.RuntimeHandle{}, err
|
|
}
|
|
if err := r.waitForPaneReady(ctx, id, paneID); err != nil {
|
|
_ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id})
|
|
return ports.RuntimeHandle{}, err
|
|
}
|
|
handle := ports.RuntimeHandle{ID: handleIDValue(id, paneID)}
|
|
alive, err := r.IsAlive(ctx, handle)
|
|
if err != nil {
|
|
_ = r.Destroy(context.Background(), handle)
|
|
return ports.RuntimeHandle{}, fmt.Errorf("zellij runtime: verify session %s: %w", id, err)
|
|
}
|
|
if !alive {
|
|
_ = r.Destroy(context.Background(), handle)
|
|
return ports.RuntimeHandle{}, fmt.Errorf("zellij runtime: session %s exited before ready", id)
|
|
}
|
|
cleanupOnFailure = false
|
|
return handle, nil
|
|
}
|
|
|
|
// createSession runs `zellij attach --create-background`. On Windows we spawn
|
|
// it via runner.Start (fire-and-forget) because the inherited daemon stdio
|
|
// confuses zellij's own readiness probe; on Unix we keep the synchronous run.
|
|
func (r *Runtime) createSession(ctx context.Context, id, layoutPath string, env map[string]string) error {
|
|
args := createSessionArgs(id, layoutPath)
|
|
if runtime.GOOS != "windows" {
|
|
_, err := r.run(ctx, args...)
|
|
return err
|
|
}
|
|
return r.startWithEnv(env, args...)
|
|
}
|
|
|
|
// Destroy kills the handle's zellij session and deletes its serialized state,
|
|
// so the session can never be resurrected by a later `zellij attach`. An
|
|
// already-gone session is treated as success.
|
|
func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error {
|
|
id, _, err := handleID(handle)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := r.run(ctx, deleteSessionArgs(id)...)
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) && deleteSessionMissingOutput(string(out)) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("zellij runtime: destroy session %s: %w", id, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SendMessage pastes a message into the session's pane (chunked) and presses
|
|
// Enter to submit it.
|
|
func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error {
|
|
id, paneID, err := handleID(handle)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, chunk := range chunks(message, r.chunkSize) {
|
|
if _, err := r.run(ctx, pasteArgs(id, paneID, chunk)...); err != nil {
|
|
return fmt.Errorf("zellij runtime: paste message %s/%s: %w", id, paneID, err)
|
|
}
|
|
}
|
|
if _, err := r.run(ctx, sendEnterArgs(id, paneID)...); err != nil {
|
|
return fmt.Errorf("zellij runtime: send enter %s/%s: %w", id, paneID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetOutput returns the last `lines` lines of the session pane's screen dump.
|
|
func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) {
|
|
id, paneID, err := handleID(handle)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if lines <= 0 {
|
|
return "", errors.New("zellij runtime: lines must be positive")
|
|
}
|
|
out, err := r.run(ctx, dumpScreenArgs(id, paneID)...)
|
|
if err != nil {
|
|
return "", fmt.Errorf("zellij runtime: capture output %s/%s: %w", id, paneID, err)
|
|
}
|
|
return tailLines(trimTrailingBlankLines(string(out)), lines), nil
|
|
}
|
|
|
|
// IsAlive reports whether the handle's session still appears in `zellij
|
|
// list-sessions`. Only the documented "no sessions exist" failure counts as a
|
|
// definitive "not alive"; any other list-sessions failure is reported as a
|
|
// probe error so callers (the reaper feeding the LCM) treat it as a failed
|
|
// probe, never as proof of death.
|
|
func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) {
|
|
id, _, err := handleID(handle)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
out, err := r.run(ctx, listSessionsArgs()...)
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) && noActiveSessionsOutput(string(out)) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("zellij runtime: probe session %s: %w", id, err)
|
|
}
|
|
return sessionListedAlive(string(out), id), nil
|
|
}
|
|
|
|
// noActiveSessionsOutput reports whether a non-zero `zellij list-sessions`
|
|
// failed because no sessions exist at all — the one exit-error case that is a
|
|
// definitive "dead" rather than a probe failure. zellij 0.44 emits either
|
|
// "No active zellij sessions found." or "There is no active session!".
|
|
func noActiveSessionsOutput(out string) bool {
|
|
s := strings.ToLower(out)
|
|
return strings.Contains(s, "no active") && strings.Contains(s, "session")
|
|
}
|
|
|
|
func deleteSessionMissingOutput(out string) bool {
|
|
s := strings.ToLower(out)
|
|
if noActiveSessionsOutput(s) {
|
|
return true
|
|
}
|
|
return strings.Contains(s, "session") &&
|
|
(strings.Contains(s, "not found") ||
|
|
strings.Contains(s, "does not exist") ||
|
|
strings.Contains(s, "not exist") ||
|
|
strings.Contains(s, "not a session"))
|
|
}
|
|
|
|
// AttachCommand returns the argv a human runs to attach their terminal to the
|
|
// session, plus an optional env block that the spawn should apply (used on
|
|
// Windows where wrapping the attach in an `env` shim is unsafe under ConPTY).
|
|
func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, []string, error) {
|
|
id, _, err := handleID(handle)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
args := append([]string{}, r.baseArgs()...)
|
|
args = append(args, attachArgs(id)...)
|
|
argv, env := attachCommandWithEnv(r.binary, r.socketDir, args...)
|
|
return argv, env, nil
|
|
}
|
|
|
|
func (r *Runtime) ensureSupportedVersion(ctx context.Context) error {
|
|
out, err := r.run(ctx, versionArgs()...)
|
|
if err != nil {
|
|
return fmt.Errorf("zellij runtime: check version: %w", err)
|
|
}
|
|
if _, err := CheckVersionOutput(string(out)); err != nil {
|
|
return fmt.Errorf("zellij runtime: check version: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Runtime) writeLayout(cfg ports.RuntimeConfig) (string, map[string]string, func(), error) {
|
|
launchEnv := cfg.Env
|
|
var cleanupLaunchSpec func()
|
|
if runtime.GOOS == "windows" {
|
|
specPath, err := agentlaunch.WriteTemp(agentlaunch.Spec{
|
|
WorkspacePath: cfg.WorkspacePath,
|
|
Argv: cfg.Argv,
|
|
FallbackArgv: windowsFallbackShellArgv(r.shell),
|
|
})
|
|
if err != nil {
|
|
return "", nil, nil, fmt.Errorf("zellij runtime: %w", err)
|
|
}
|
|
cleanupLaunchSpec = func() { _ = os.Remove(specPath) }
|
|
cfg.Argv = windowsLaunchArgv(r.launcher)
|
|
launchEnv = windowsLaunchEnv(cfg.Env, r.launcher, specPath)
|
|
}
|
|
|
|
file, err := os.CreateTemp(os.TempDir(), "ao-zellij-layout-*.kdl")
|
|
if err != nil {
|
|
if cleanupLaunchSpec != nil {
|
|
cleanupLaunchSpec()
|
|
}
|
|
return "", nil, nil, fmt.Errorf("zellij runtime: create layout temp file: %w", err)
|
|
}
|
|
path := file.Name()
|
|
if _, err := file.WriteString(buildLayout(cfg, r.shell)); err != nil {
|
|
_ = file.Close()
|
|
_ = os.Remove(path)
|
|
if cleanupLaunchSpec != nil {
|
|
cleanupLaunchSpec()
|
|
}
|
|
return "", nil, nil, fmt.Errorf("zellij runtime: write layout temp file: %w", err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
_ = os.Remove(path)
|
|
if cleanupLaunchSpec != nil {
|
|
cleanupLaunchSpec()
|
|
}
|
|
return "", nil, nil, fmt.Errorf("zellij runtime: close layout temp file: %w", err)
|
|
}
|
|
return path, launchEnv, cleanupLaunchSpec, nil
|
|
}
|
|
|
|
// windowsLaunchEnv augments cfg.Env with the AO_LAUNCH_SPEC pointer the `ao
|
|
// launch` trampoline reads, and prepends the launcher's directory to PATH so
|
|
// the trampoline (i.e. `ao` itself) is resolvable in the spawned environment.
|
|
func windowsLaunchEnv(env map[string]string, launcherBinary, specPath string) map[string]string {
|
|
launchEnv := make(map[string]string, len(env)+2)
|
|
for k, v := range env {
|
|
launchEnv[k] = v
|
|
}
|
|
launchEnv[agentlaunch.EnvSpecPath] = specPath
|
|
if dir := launcherDir(launcherBinary); dir != "" {
|
|
base := launchEnv["PATH"]
|
|
if base == "" {
|
|
base = getenv("PATH")
|
|
}
|
|
if base == "" {
|
|
launchEnv["PATH"] = dir
|
|
} else {
|
|
launchEnv["PATH"] = dir + string(os.PathListSeparator) + base
|
|
}
|
|
}
|
|
return launchEnv
|
|
}
|
|
|
|
func launcherDir(launcherBinary string) string {
|
|
if launcherBinary == "" || !filepath.IsAbs(launcherBinary) {
|
|
return ""
|
|
}
|
|
return filepath.Dir(launcherBinary)
|
|
}
|
|
|
|
func (r *Runtime) findAgentPane(ctx context.Context, id string) (string, error) {
|
|
deadline := time.Now().Add(r.timeout)
|
|
var lastErr error
|
|
for {
|
|
out, err := r.run(ctx, listPanesArgs(id)...)
|
|
if err == nil {
|
|
paneID, parseErr := agentPaneID(out)
|
|
if parseErr == nil {
|
|
return paneID, nil
|
|
}
|
|
lastErr = parseErr
|
|
} else {
|
|
lastErr = err
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return "", fmt.Errorf("zellij runtime: list panes %s: %w", id, lastErr)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Runtime) waitForPaneReady(ctx context.Context, id, paneID string) error {
|
|
if runtime.GOOS != "windows" {
|
|
return nil
|
|
}
|
|
|
|
deadline := time.Now().Add(r.timeout)
|
|
var lastErr error
|
|
for {
|
|
out, err := r.run(ctx, listPanesArgs(id)...)
|
|
if err == nil {
|
|
pane, parseErr := paneByID(out, paneID)
|
|
if parseErr == nil {
|
|
if pane.Exited {
|
|
return fmt.Errorf("zellij runtime: pane %s/%s exited before ready", id, paneID)
|
|
}
|
|
if paneReady(pane) {
|
|
return nil
|
|
}
|
|
lastErr = fmt.Errorf("pane %s/%s is not ready", id, paneID)
|
|
} else {
|
|
lastErr = parseErr
|
|
}
|
|
} else {
|
|
lastErr = err
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("zellij runtime: wait for pane %s/%s: %w", id, paneID, lastErr)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) {
|
|
cmdCtx, cancel := context.WithTimeout(ctx, r.timeout)
|
|
defer cancel()
|
|
fullArgs := append(r.baseArgs(), args...)
|
|
out, err := r.runner.Run(cmdCtx, r.env(), r.binary, fullArgs...)
|
|
if cmdCtx.Err() != nil {
|
|
return out, cmdCtx.Err()
|
|
}
|
|
if err != nil {
|
|
return out, commandError{err: err, output: strings.TrimSpace(string(out))}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// startWithEnv fires zellij in the background with extra env vars merged onto
|
|
// the runtime's base env. Used by the Windows createSession path so the daemon
|
|
// is not blocked waiting on zellij's `--create-background` to settle.
|
|
func (r *Runtime) startWithEnv(extra map[string]string, args ...string) error {
|
|
fullArgs := append(r.baseArgs(), args...)
|
|
if err := r.runner.Start(r.envWith(extra), r.binary, fullArgs...); err != nil {
|
|
return commandError{err: err}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Runtime) baseArgs() []string {
|
|
args := []string{}
|
|
if r.configDir != "" {
|
|
args = append(args, "--config-dir", r.configDir)
|
|
}
|
|
return args
|
|
}
|
|
|
|
func (r *Runtime) env() []string {
|
|
return r.envWith(nil)
|
|
}
|
|
|
|
func (r *Runtime) envWith(extra map[string]string) []string {
|
|
env := zellijColorEnv(nil)
|
|
if r.socketDir == "" {
|
|
return appendRuntimeEnv(env, extra)
|
|
}
|
|
env = append(env, "ZELLIJ_SOCKET_DIR="+r.socketDir)
|
|
return appendRuntimeEnv(env, extra)
|
|
}
|
|
|
|
func appendRuntimeEnv(env []string, extra map[string]string) []string {
|
|
for _, key := range sortedKeys(extra) {
|
|
env = append(env, key+"="+extra[key])
|
|
}
|
|
return env
|
|
}
|
|
|
|
func attachCommandWithEnv(binary, socketDir string, args ...string) ([]string, []string) {
|
|
if runtime.GOOS == "windows" {
|
|
// Windows ConPTY attaches the child directly. Avoid shell wrappers here:
|
|
// malformed ConPTY startup around powershell.exe/cmd.exe surfaces as modal
|
|
// application-error dialogs. Per-session ZELLIJ_SOCKET_DIR is delivered
|
|
// via the spawn's env block (CreateProcess) instead of an `env` shim.
|
|
var envBlock []string
|
|
if socketDir != "" {
|
|
envBlock = upsertEnv(append([]string(nil), os.Environ()...), "ZELLIJ_SOCKET_DIR="+socketDir)
|
|
}
|
|
return append([]string{binary}, args...), envBlock
|
|
}
|
|
env := zellijColorEnv(nil)
|
|
if socketDir != "" {
|
|
env = append(env, "ZELLIJ_SOCKET_DIR="+socketDir)
|
|
}
|
|
argv := []string{"env", "-u", "NO_COLOR"}
|
|
argv = append(argv, env...)
|
|
argv = append(argv, binary)
|
|
return append(argv, args...), nil
|
|
}
|
|
|
|
func zellijCommandEnv(base, overrides []string) []string {
|
|
env := zellijColorEnv(append([]string(nil), base...))
|
|
for _, pair := range overrides {
|
|
env = upsertEnv(env, pair)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func zellijColorEnv(env []string) []string {
|
|
if runtime.GOOS == "windows" {
|
|
return env
|
|
}
|
|
env = removeEnv(env, "NO_COLOR")
|
|
env = upsertEnv(env, "TERM="+defaultZellijTerm)
|
|
env = upsertEnv(env, "COLORTERM="+defaultZellijColor)
|
|
return env
|
|
}
|
|
|
|
func upsertEnv(env []string, pair string) []string {
|
|
key, _, ok := strings.Cut(pair, "=")
|
|
if !ok {
|
|
return env
|
|
}
|
|
prefix := key + "="
|
|
for i, current := range env {
|
|
if strings.HasPrefix(current, prefix) {
|
|
env[i] = pair
|
|
return env
|
|
}
|
|
}
|
|
return append(env, pair)
|
|
}
|
|
|
|
func removeEnv(env []string, key string) []string {
|
|
prefix := key + "="
|
|
out := env[:0]
|
|
for _, current := range env {
|
|
if strings.HasPrefix(current, prefix) {
|
|
continue
|
|
}
|
|
out = append(out, current)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func zellijSessionName(id domain.SessionID) (string, error) {
|
|
raw := string(id)
|
|
if raw == "" {
|
|
return "", errors.New("zellij runtime: session id is required")
|
|
}
|
|
return SessionName(raw), nil
|
|
}
|
|
|
|
// SessionName returns the zellij session name the runtime registers for a given
|
|
// session id — applying the same sanitisation Create does. Callers that print an
|
|
// attach hint (e.g. `ao spawn`) must use this rather than the raw id, since a
|
|
// long or non-conforming id maps to a different, sanitised session name.
|
|
func SessionName(id string) string {
|
|
if sessionIDPattern.MatchString(id) && len(id) <= 48 {
|
|
return id
|
|
}
|
|
return sanitizedSessionName(id)
|
|
}
|
|
|
|
func sanitizedSessionName(raw string) string {
|
|
var b strings.Builder
|
|
lastDash := false
|
|
for _, r := range raw {
|
|
valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-'
|
|
if valid {
|
|
b.WriteRune(r)
|
|
lastDash = false
|
|
continue
|
|
}
|
|
if !lastDash {
|
|
b.WriteByte('-')
|
|
lastDash = true
|
|
}
|
|
}
|
|
base := strings.Trim(b.String(), "-")
|
|
if base == "" {
|
|
base = "session"
|
|
}
|
|
if len(base) > 32 {
|
|
base = strings.TrimRight(base[:32], "-")
|
|
}
|
|
sum := sha256.Sum256([]byte(raw))
|
|
return base + "-" + hex.EncodeToString(sum[:4])
|
|
}
|
|
|
|
func validateSessionID(id string) error {
|
|
if id == "" {
|
|
return errors.New("zellij runtime: session id is required")
|
|
}
|
|
if !sessionIDPattern.MatchString(id) {
|
|
return fmt.Errorf("zellij runtime: invalid session id %q", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePaneID(id string) error {
|
|
if id == "" {
|
|
return errors.New("zellij runtime: pane id is required")
|
|
}
|
|
if !paneIDPattern.MatchString(id) {
|
|
return fmt.Errorf("zellij runtime: invalid pane id %q", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func handleID(handle ports.RuntimeHandle) (string, string, error) {
|
|
parts := strings.Split(handle.ID, "/")
|
|
if len(parts) == 1 {
|
|
if err := validateSessionID(parts[0]); err != nil {
|
|
return "", "", err
|
|
}
|
|
return parts[0], terminalPaneID(0), nil
|
|
}
|
|
if len(parts) != 2 {
|
|
return "", "", fmt.Errorf("zellij runtime: invalid handle id %q", handle.ID)
|
|
}
|
|
if err := validateSessionID(parts[0]); err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := validatePaneID(parts[1]); err != nil {
|
|
return "", "", err
|
|
}
|
|
return parts[0], parts[1], nil
|
|
}
|
|
|
|
type paneInfo struct {
|
|
ID int `json:"id"`
|
|
IsPlugin bool `json:"is_plugin"`
|
|
Title string `json:"title"`
|
|
Exited bool `json:"exited"`
|
|
TerminalCommand string `json:"terminal_command"`
|
|
PaneCommand string `json:"pane_command"`
|
|
}
|
|
|
|
func agentPaneID(out []byte) (string, error) {
|
|
panes, err := parsePanes(out)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, pane := range panes {
|
|
if !pane.IsPlugin && pane.Title == agentPaneName {
|
|
return terminalPaneID(pane.ID), nil
|
|
}
|
|
}
|
|
for _, pane := range panes {
|
|
if !pane.IsPlugin {
|
|
return terminalPaneID(pane.ID), nil
|
|
}
|
|
}
|
|
return "", errors.New("agent pane not found")
|
|
}
|
|
|
|
func paneByID(out []byte, paneID string) (paneInfo, error) {
|
|
panes, err := parsePanes(out)
|
|
if err != nil {
|
|
return paneInfo{}, err
|
|
}
|
|
for _, pane := range panes {
|
|
if !pane.IsPlugin && terminalPaneID(pane.ID) == paneID {
|
|
return pane, nil
|
|
}
|
|
}
|
|
return paneInfo{}, fmt.Errorf("pane %s not found", paneID)
|
|
}
|
|
|
|
func parsePanes(out []byte) ([]paneInfo, error) {
|
|
var panes []paneInfo
|
|
if err := json.Unmarshal(out, &panes); err != nil {
|
|
return nil, fmt.Errorf("parse panes: %w", err)
|
|
}
|
|
return panes, nil
|
|
}
|
|
|
|
func paneReady(pane paneInfo) bool {
|
|
if pane.PaneCommand != "" {
|
|
return true
|
|
}
|
|
return pane.TerminalCommand == ""
|
|
}
|
|
|
|
func chunks(s string, maxBytes int) []string {
|
|
if s == "" {
|
|
return []string{""}
|
|
}
|
|
if maxBytes <= 0 || len(s) <= maxBytes {
|
|
return []string{s}
|
|
}
|
|
parts := []string{}
|
|
for s != "" {
|
|
if len(s) <= maxBytes {
|
|
parts = append(parts, s)
|
|
break
|
|
}
|
|
end := maxBytes
|
|
for end > 0 && !utf8.ValidString(s[:end]) {
|
|
end--
|
|
}
|
|
if end == 0 {
|
|
_, size := utf8.DecodeRuneInString(s)
|
|
end = size
|
|
}
|
|
parts = append(parts, s[:end])
|
|
s = s[end:]
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func tailLines(s string, n int) string {
|
|
if n <= 0 || s == "" {
|
|
return ""
|
|
}
|
|
lines := strings.SplitAfter(s, "\n")
|
|
if lines[len(lines)-1] == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
if len(lines) <= n {
|
|
return s
|
|
}
|
|
return strings.Join(lines[len(lines)-n:], "")
|
|
}
|
|
|
|
func trimTrailingBlankLines(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
lines := strings.SplitAfter(s, "\n")
|
|
if lines[len(lines)-1] == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
for len(lines) > 0 && strings.TrimRight(lines[len(lines)-1], "\r\n") == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
return strings.Join(lines, "")
|
|
}
|
|
|
|
// RequiredVersion returns the minimum Zellij version AO's runtime adapter
|
|
// supports.
|
|
func RequiredVersion() string { return minSupportedVersion().String() }
|
|
|
|
// CheckVersionOutput parses `zellij --version` output, returning the parsed
|
|
// version when it satisfies AO's minimum runtime requirement.
|
|
func CheckVersionOutput(out string) (string, error) {
|
|
version, err := parseVersion(out)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if compareVersion(version, minSupportedVersion()) < 0 {
|
|
return version.String(), fmt.Errorf("unsupported zellij version %s; require >= %s", version, RequiredVersion())
|
|
}
|
|
return version.String(), nil
|
|
}
|
|
|
|
func minSupportedVersion() semver { return semver{minMajor, minMinor, minPatch} }
|
|
|
|
type semver struct {
|
|
major int
|
|
minor int
|
|
patch int
|
|
}
|
|
|
|
func (v semver) String() string {
|
|
return fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch)
|
|
}
|
|
|
|
func parseVersion(out string) (semver, error) {
|
|
fields := strings.Fields(strings.TrimSpace(out))
|
|
if len(fields) == 0 {
|
|
return semver{}, errors.New("empty version output")
|
|
}
|
|
raw := strings.TrimPrefix(fields[len(fields)-1], "v")
|
|
parts := strings.Split(raw, ".")
|
|
if len(parts) < 3 {
|
|
return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out))
|
|
}
|
|
major, err := parseVersionPart(parts[0])
|
|
if err != nil {
|
|
return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out))
|
|
}
|
|
minor, err := parseVersionPart(parts[1])
|
|
if err != nil {
|
|
return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out))
|
|
}
|
|
patch, err := parseVersionPart(parts[2])
|
|
if err != nil {
|
|
return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out))
|
|
}
|
|
return semver{major: major, minor: minor, patch: patch}, nil
|
|
}
|
|
|
|
func parseVersionPart(s string) (int, error) {
|
|
end := 0
|
|
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
|
|
end++
|
|
}
|
|
if end == 0 {
|
|
return 0, errors.New("missing version number")
|
|
}
|
|
return strconv.Atoi(s[:end])
|
|
}
|
|
|
|
func compareVersion(a, b semver) int {
|
|
if a.major != b.major {
|
|
return a.major - b.major
|
|
}
|
|
if a.minor != b.minor {
|
|
return a.minor - b.minor
|
|
}
|
|
return a.patch - b.patch
|
|
}
|
|
|
|
func sessionListedAlive(out, id string) bool {
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) == 0 || fields[0] != id {
|
|
continue
|
|
}
|
|
return !strings.Contains(line, "(EXITED")
|
|
}
|
|
return false
|
|
}
|
|
|
|
type commandError struct {
|
|
err error
|
|
output string
|
|
}
|
|
|
|
func (e commandError) Error() string {
|
|
if e.output == "" {
|
|
return e.err.Error()
|
|
}
|
|
return e.err.Error() + ": " + e.output
|
|
}
|
|
|
|
func (e commandError) Unwrap() error { return e.err }
|