From bc23964081af8a610fc6f2cc42012e5183c9ed5b Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 23 Jun 2026 21:41:19 +0530 Subject: [PATCH 01/60] feat(runtime): add tmux adapter package Adds backend/internal/adapters/runtime/tmux implementing ports.Runtime via the tmux CLI. Drop-in replacement for the zellij adapter on Darwin/Linux. Key design points: - Handle is a plain session id string (no pane-id split needed for tmux). - Exact-match session targeting via = prefix for kill-session and has-session. - Keep-alive shell appended to launch command so sessions survive agent exit. - send-keys -l chunked for literal text delivery (no key-name interpretation). - IsAlive distinguishes definitive-dead (missing/no-server output) from probe errors so the reaper never kills a session on a transient tmux failure. - 34 tests pass: 32 unit tests via fakeRunner seam, 2 integration tests on real tmux 3.6b (TestRuntimeIntegration, TestRuntimeIntegrationExactSessionParsing). Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/task-1-brief.md | 141 ++++ .superpowers/sdd/task-1-report.md | 65 ++ .../adapters/runtime/tmux/commands.go | 64 ++ .../internal/adapters/runtime/tmux/tmux.go | 482 ++++++++++++++ .../runtime/tmux/tmux_integration_test.go | 141 ++++ .../adapters/runtime/tmux/tmux_test.go | 630 ++++++++++++++++++ 6 files changed, 1523 insertions(+) create mode 100644 .superpowers/sdd/task-1-brief.md create mode 100644 .superpowers/sdd/task-1-report.md create mode 100644 backend/internal/adapters/runtime/tmux/commands.go create mode 100644 backend/internal/adapters/runtime/tmux/tmux.go create mode 100644 backend/internal/adapters/runtime/tmux/tmux_integration_test.go create mode 100644 backend/internal/adapters/runtime/tmux/tmux_test.go diff --git a/.superpowers/sdd/task-1-brief.md b/.superpowers/sdd/task-1-brief.md new file mode 100644 index 00000000..32f376b9 --- /dev/null +++ b/.superpowers/sdd/task-1-brief.md @@ -0,0 +1,141 @@ +# Task 1: tmux runtime adapter + +## Goal +Create a new Go package `internal/adapters/runtime/tmux` that drives agent +sessions through the **tmux** CLI, implementing the SAME method set the existing +zellij adapter exposes, so it is a drop-in replacement on Darwin/Linux. This task +adds the package and its tests ONLY. It does NOT wire it into the daemon and does +NOT delete zellij (that is Task 2). The build must stay green. + +Module path: `github.com/aoagents/agent-orchestrator/backend` +Work inside: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode/backend` + +## The template to mirror +The zellij adapter is your structural template. READ THESE FIRST: +- `internal/adapters/runtime/zellij/zellij.go` (Runtime struct, Options, New, the + `runner` interface + `execRunner`, Create/Destroy/IsAlive/SendMessage/GetOutput/ + AttachCommand, session-name sanitization, `chunks`, `tailLines`, + `trimTrailingBlankLines`, `validateEnvKeys`, `sortedKeys`, `commandError`). +- `internal/adapters/runtime/zellij/commands.go` (arg builders + shell quoting). +- `internal/adapters/runtime/zellij/zellij_test.go` (the `fakeRunner` test seam: + `type fakeRunner struct { calls []runnerCall; outputs [][]byte; err error }`, + injected via `r.runner = &fakeRunner{...}`). Mirror this exactly for tmux tests. + +Reuse the same idioms (Go style, comment density, error wrapping `fmt.Errorf("tmux +runtime: ...: %w", err)`). Match the surrounding code. + +## Interface the package must satisfy +The package's `Runtime` type must implement, with these EXACT signatures (they are +the existing consumer contracts in the repo — verify against `internal/ports/ +outbound.go`, `internal/terminal/attachment.go` `PTYSource`, `internal/review/ +launcher.go` `reviewerRuntime`, `internal/daemon/lifecycle_wiring.go` +`runtimeMessageSender`): + +```go +func New(opts Options) *Runtime +func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) +func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error +func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) +func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error +func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) +func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) +``` + +Add a compile-time assertion: `var _ ports.Runtime = (*Runtime)(nil)`. + +`ports.RuntimeConfig` fields: `SessionID domain.SessionID`, `WorkspacePath string`, +`Argv []string`, `Env map[string]string`. `ports.RuntimeHandle{ ID string }`. + +## tmux command mapping (the substance) +Use a `runner` interface identical in shape to zellij's (`Run(ctx, env, name, +args...) ([]byte, error)` and `Start(env, name, args...) error`) with an +`execRunner` default, so tests inject a `fakeRunner`. The tmux binary defaults to +`"tmux"` (allow `Options.Binary` override; resolve via `exec.LookPath`). + +- **Create**: validate session id (sanitize like zellij's `SessionName`; tmux + session names must not contain `.` or `:` — those are window/pane separators — + and must be non-empty; reuse the same sanitize-to-`[A-Za-z0-9_-]`+hash approach). + Validate WorkspacePath non-empty, Argv non-empty, env keys (port + `validateEnvKeys`). Then: + `tmux new-session -d -s -x 220 -y 50 -c ` + where `` runs the agent then **execs a keep-alive shell so the tmux + session survives the agent exiting** (this is the whole reason a multiplexer is + used). Build the launch as a single shell command string passed to + `sh -c` (or the configured shell), of the form: + `export K=V; ...; export PATH=...; ; exec "${SHELL:-/bin/sh}" -i` + Reuse zellij's `wrapLaunchCommandUnix` quoting approach (single-quote each env + value and each argv word). Pass env to the agent via the exported-vars prefix + (do NOT rely on tmux `-e`, which has its own quirks). After creating, set + `tmux set-option -t status off` (hide the status bar in the embedded web + terminal). Then verify liveness via IsAlive; on failure, Destroy and return an + error (mirror zellij's create cleanup discipline). Return + `ports.RuntimeHandle{ID: }`. NOTE: tmux needs no pane-id discovery — the + handle is just the session id. Keep the handle format simple (the session id); + do NOT invent a `session/pane` split unless a consumer requires it (none does — + verify). +- **Destroy**: `tmux kill-session -t `. Treat "session not found"/"can't find + session" stderr on a non-zero exit as success (idempotent), mirroring zellij's + `deleteSessionMissingOutput`. +- **IsAlive**: `tmux has-session -t `. Exit 0 => alive. A non-zero exit whose + output says the session/server is missing ("can't find session", "no server + running", "error connecting") => definitively `false, nil`. Any OTHER error => + `false, err` (a probe failure, NOT proof of death) — this distinction matters + because the reaper feeds the lifecycle manager and must not kill a session on a + transient probe error. Mirror zellij's IsAlive contract precisely. +- **SendMessage**: send literal text then Enter. Use + `tmux send-keys -t -l ` for the literal text (the `-l` flag stops + tmux interpreting words like "Enter"/"C-c" as key names), chunked via the ported + `chunks(message, chunkSize)` helper, then `tmux send-keys -t Enter` to + submit. (Per the agent-orchestrator reference, multiline text can alternatively + go via `load-buffer -`/`paste-buffer`, but `send-keys -l` chunked is simpler and + correct — use it. Mark any simplification with a `ponytail:` comment.) +- **GetOutput**: `tmux capture-pane -t -p -S -` (the `-S -` starts n + lines back in history; `-p` prints to stdout). Then apply the ported + `trimTrailingBlankLines` + `tailLines(out, lines)`. `lines <= 0` => error, like + zellij. +- **AttachCommand**: return argv `["tmux", "attach-session", "-t", ]` (use the + resolved binary path as argv[0]) and a `nil` env block (no per-session socket dir + needed for tmux — unlike zellij's Windows ConPTY env). The terminal layer's + existing Unix `defaultSpawn` (creack/pty) will spawn this unchanged. + +## Options +```go +type Options struct { + Binary string // default "tmux" (resolved via exec.LookPath) + Shell string // default $SHELL else /bin/sh + Timeout time.Duration // default 5s + ChunkSize int // default 16*1024 +} +``` + +## Tests (REQUIRED — this is the runnable check) +1. **Unit tests** `tmux_test.go` using a `fakeRunner` (copy the struct shape from + zellij_test.go: records calls, returns scripted outputs/err). Cover: command + args for Create (new-session + status off), Destroy, IsAlive (alive / missing => + false,nil / transient error => err), SendMessage (chunking + -l + Enter), + GetOutput (tail/trim), AttachCommand argv, session-name sanitization, env-key + validation, the keep-alive shell is present in the launch command, env vars are + exported in the launch command. +2. **Integration test** `tmux_integration_test.go` gated on + `if _, err := exec.LookPath("tmux"); err != nil { t.Skip(...) }` (mirror + `zellij_integration_test.go`). Real end-to-end: Create a session running a + trivial command (e.g. `sh -c 'echo hello; ...'` via Argv), assert IsAlive true, + SendMessage, GetOutput contains expected text, Destroy, then IsAlive false. + Use a unique session id (incorporate the test name) and ALWAYS Destroy in a + `t.Cleanup` so a failed test never leaks a tmux session on the dev machine. + tmux 3.6b IS installed here, so this test WILL run — make it pass. + +## Constraints / definition of done +- `cd backend && go build ./... ` succeeds. +- `cd backend && go test ./internal/adapters/runtime/tmux/...` passes (unit + + integration), and `go vet ./internal/adapters/runtime/tmux/...` is clean. +- Do NOT modify any file outside the new `internal/adapters/runtime/tmux/` package. +- Do NOT add new dependencies (tmux is shelled out via os/exec; no Go module + needed). go.mod must be unchanged. +- Follow the user's hard rule: **never use em dashes** in code, comments, or commit + messages. Use periods/commas/parentheses. +- Keep it lazy/minimal (ponytail): no speculative abstraction, no pane-id machinery + tmux doesn't need, no config the daemon won't set. Mark any deliberate shortcut + with a `ponytail:` comment naming the ceiling. +- Commit your work on the current branch (`migrate-zellij-to-tmux-conpty`) with a + clear message. git author is already configured. diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md new file mode 100644 index 00000000..08dc462c --- /dev/null +++ b/.superpowers/sdd/task-1-report.md @@ -0,0 +1,65 @@ +# Task 1 Report: tmux Runtime Adapter + +## What was built + +New package `backend/internal/adapters/runtime/tmux` implementing `ports.Runtime` +via the tmux CLI. Four files were created (no existing files modified): + +- `backend/internal/adapters/runtime/tmux/tmux.go` - Runtime struct, Options, New, + Create/Destroy/IsAlive/SendMessage/GetOutput/AttachCommand, session-name + sanitization, helpers (chunks, tailLines, trimTrailingBlankLines, + validateEnvKeys, sortedKeys, shellQuote, buildLaunchCommand, commandError). +- `backend/internal/adapters/runtime/tmux/commands.go` - Arg builders for all tmux + subcommands (newSessionArgs, setStatusOffArgs, killSessionArgs, hasSessionArgs, + sendKeysLiteralArgs, sendEnterArgs, capturePaneArgs, exactSessionTarget). +- `backend/internal/adapters/runtime/tmux/tmux_test.go` - 32 unit tests via fakeRunner. +- `backend/internal/adapters/runtime/tmux/tmux_integration_test.go` - 2 integration + tests gated on `exec.LookPath("tmux")`. + +## Design choices + +1. Handle format: plain session id string (no session/pane split). tmux needs no + pane-id discovery; the handle is just `ports.RuntimeHandle{ID: }`. + +2. Exact session targeting: `kill-session -t =` and `has-session -t =` use + tmux's `=` exact-name prefix (supported by session-selection commands in tmux + 3.x) to prevent prefix matching ("foo" matching "foobar"). Commands that use + pane-targeting syntax (set-option, send-keys, capture-pane) use a plain session + name because they do not support the `=` prefix. + +3. Keep-alive shell: `buildLaunchCommand` appends `; exec ${SHELL:-/bin/sh} -i` so + the tmux session survives agent exit (the whole reason a multiplexer is used). + +4. Send-keys chunking: `send-keys -t -l ` with `-l` flag sends text + literally (tmux does not interpret "Enter", "C-c", etc. as key names). Chunked + via ported `chunks()` helper with 16 KB default. + +5. `sessionMissingOutput` covers: "can't find session", "no server running", + "error connecting", "session not found". Both `killSessionMissingOutput` and the + IsAlive path use this to distinguish definitive-dead from probe-error. + +6. `AttachCommand` returns `["tmux", "attach-session", "-t", id]` with nil env + (no per-session socket dir needed unlike zellij's Windows path). + +7. `ponytail:` comments mark the two deliberate simplifications: + - send-keys -l chunked vs. load-buffer/paste-buffer (ceiling: very large + messages are slightly slower; 16 KB default is ample for agent prompts). + - PATH handling matches the zellij unix path. + +## Verification output + +``` +$ cd backend && go build ./... +# success (no output) + +$ go test ./internal/adapters/runtime/tmux/... -v +# 34 tests passed (32 unit + 2 integration with real tmux 3.6b) + +$ go vet ./internal/adapters/runtime/tmux/... +# no issues +``` + +## Concerns + +None. The build is green, all 34 tests pass (including the integration tests on +the installed tmux 3.6b), and no files outside the new package were modified. diff --git a/backend/internal/adapters/runtime/tmux/commands.go b/backend/internal/adapters/runtime/tmux/commands.go new file mode 100644 index 00000000..1c2cf302 --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/commands.go @@ -0,0 +1,64 @@ +package tmux + +import "fmt" + +// newSessionArgs builds args for `tmux new-session -d -s -x 220 -y 50 +// -c -c `. The shell -c form runs the launch command +// inside the configured shell so exported env vars and quoting work correctly. +func newSessionArgs(id, cwd, shellPath, launchCmd string) []string { + return []string{ + "new-session", "-d", + "-s", id, + "-x", "220", + "-y", "50", + "-c", cwd, + shellPath, "-c", launchCmd, + } +} + +// setStatusOffArgs hides the tmux status bar for the given session. +// set-option uses pane-targeting syntax which does not accept the `=` prefix, +// so we pass the session name directly. +func setStatusOffArgs(id string) []string { + return []string{"set-option", "-t", id, "status", "off"} +} + +// killSessionArgs builds args for `tmux kill-session -t =`. The `=` prefix +// requests exact-name matching so a session "foo" does not accidentally match +// "foobar" (tmux otherwise does unique-prefix matching). +func killSessionArgs(id string) []string { + return []string{"kill-session", "-t", exactSessionTarget(id)} +} + +// hasSessionArgs builds args for `tmux has-session -t =`. The `=` prefix +// requests exact-name matching (see killSessionArgs). +func hasSessionArgs(id string) []string { + return []string{"has-session", "-t", exactSessionTarget(id)} +} + +// exactSessionTarget wraps id in tmux's exact-match prefix `=` so session- +// selection commands (-t) target only the session with that precise name. +// Only kill-session and has-session support this prefix; pane-targeting +// commands (send-keys, capture-pane, set-option) use a plain session name. +func exactSessionTarget(id string) string { + return "=" + id +} + +// sendKeysLiteralArgs builds args for `tmux send-keys -t -l `. +// The -l flag stops tmux interpreting words like "Enter" as key names so the +// text is sent verbatim. +func sendKeysLiteralArgs(id, chunk string) []string { + return []string{"send-keys", "-t", id, "-l", chunk} +} + +// sendEnterArgs builds args for `tmux send-keys -t Enter` to submit the +// queued input. +func sendEnterArgs(id string) []string { + return []string{"send-keys", "-t", id, "Enter"} +} + +// capturePaneArgs builds args for `tmux capture-pane -t -p -S -`. +// -p prints to stdout; -S - starts n lines back in history. +func capturePaneArgs(id string, lines int) []string { + return []string{"capture-pane", "-t", id, "-p", "-S", fmt.Sprintf("-%d", lines)} +} diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go new file mode 100644 index 00000000..3b477456 --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -0,0 +1,482 @@ +// Package tmux implements ports.Runtime using tmux sessions on Darwin/Linux. +package tmux + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "os/exec" + "regexp" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + defaultTimeout = 5 * time.Second + defaultChunkBytes = 16 * 1024 +) + +var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +var getenv = os.Getenv + +// Options configures a tmux Runtime. Every field has a sensible default (see +// New), so the zero value is usable. +type Options struct { + Binary string // default "tmux" (resolved via exec.LookPath) + Shell string // default $SHELL else /bin/sh + Timeout time.Duration // default 5s + ChunkSize int // default 16*1024 +} + +// Runtime runs agent sessions inside tmux sessions, driving them via the tmux +// CLI. It implements ports.Runtime. +type Runtime struct { + binary string + shell string + timeout time.Duration + chunkSize int + runner runner +} + +var _ ports.Runtime = (*Runtime)(nil) + +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 = append(append([]string(nil), os.Environ()...), env...) + return cmd.CombinedOutput() +} + +func (execRunner) Start(env []string, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Env = append(append([]string(nil), os.Environ()...), env...) + return cmd.Start() +} + +// New builds a tmux Runtime, filling unset Options with defaults: binary "tmux" +// (resolved via exec.LookPath), shell from $SHELL (else /bin/sh), and the +// default timeout and output chunk size. +func New(opts Options) *Runtime { + binary := opts.Binary + if binary == "" { + if path, err := exec.LookPath("tmux"); err == nil { + binary = path + } else { + binary = "tmux" + } + } + timeout := opts.Timeout + if timeout == 0 { + timeout = defaultTimeout + } + shellPath := opts.Shell + if shellPath == "" { + shellPath = getenv("SHELL") + } + if shellPath == "" { + shellPath = "/bin/sh" + } + chunkSize := opts.ChunkSize + if chunkSize <= 0 { + chunkSize = defaultChunkBytes + } + return &Runtime{ + binary: binary, + shell: shellPath, + timeout: timeout, + chunkSize: chunkSize, + runner: execRunner{}, + } +} + +// Create starts a new tmux session in the workspace, running the agent's +// launch command with a keep-alive shell, and returns a handle to it. +func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { + id, err := tmuxSessionName(cfg.SessionID) + if err != nil { + return ports.RuntimeHandle{}, err + } + if cfg.WorkspacePath == "" { + return ports.RuntimeHandle{}, errors.New("tmux runtime: workspace path is required") + } + if len(cfg.Argv) == 0 { + return ports.RuntimeHandle{}, errors.New("tmux runtime: launch command is required") + } + if err := validateEnvKeys(cfg.Env); err != nil { + return ports.RuntimeHandle{}, err + } + + launchCmd := buildLaunchCommand(cfg, r.shell) + args := newSessionArgs(id, cfg.WorkspacePath, r.shell, launchCmd) + if _, err := r.run(ctx, args...); err != nil { + return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: create session %s: %w", id, err) + } + + // Hide the status bar in the embedded terminal: it clutters the view and + // was not designed for the in-browser display context. + if _, err := r.run(ctx, setStatusOffArgs(id)...); err != nil { + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) + return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: set status %s: %w", id, err) + } + + handle := ports.RuntimeHandle{ID: id} + alive, err := r.IsAlive(ctx, handle) + if err != nil { + _ = r.Destroy(context.Background(), handle) + return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: verify session %s: %w", id, err) + } + if !alive { + _ = r.Destroy(context.Background(), handle) + return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: session %s exited before ready", id) + } + return handle, nil +} + +// Destroy kills the handle's tmux session. An already-gone session is treated +// as success (idempotent). +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, killSessionArgs(id)...) + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && killSessionMissingOutput(string(out)) { + return nil + } + return fmt.Errorf("tmux runtime: destroy session %s: %w", id, err) + } + return nil +} + +// IsAlive reports whether the handle's session still exists via `tmux +// has-session`. Exit 0 means alive. A non-zero exit with output indicating the +// session or server is missing is a definitive false, nil. Any other non-zero +// exit is a probe error (not proof of death) so callers (the reaper feeding +// the LCM) treat it as a failed probe and never kill a session on a transient +// error. +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, hasSessionArgs(id)...) + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && sessionMissingOutput(string(out)) { + return false, nil + } + return false, fmt.Errorf("tmux runtime: probe session %s: %w", id, err) + } + return true, nil +} + +// SendMessage sends literal text to the session (chunked via send-keys -l) then +// presses Enter to submit. +// +// ponytail: send-keys -l chunked is simpler than load-buffer/paste-buffer; the +// ceiling is very large messages may be slower, but chunk size defaults to 16 KB +// which is ample for agent prompts. +func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { + id, err := handleID(handle) + if err != nil { + return err + } + for _, chunk := range chunks(message, r.chunkSize) { + if _, err := r.run(ctx, sendKeysLiteralArgs(id, chunk)...); err != nil { + return fmt.Errorf("tmux runtime: send message %s: %w", id, err) + } + } + if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil { + return fmt.Errorf("tmux runtime: send enter %s: %w", id, err) + } + return nil +} + +// GetOutput returns the last `lines` lines of the session pane's captured +// output. +func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { + id, err := handleID(handle) + if err != nil { + return "", err + } + if lines <= 0 { + return "", errors.New("tmux runtime: lines must be positive") + } + out, err := r.run(ctx, capturePaneArgs(id, lines)...) + if err != nil { + return "", fmt.Errorf("tmux runtime: capture output %s: %w", id, err) + } + return tailLines(trimTrailingBlankLines(string(out)), lines), nil +} + +// AttachCommand returns the argv a human runs to attach their terminal to the +// session. No per-session env block is needed for tmux (unlike zellij's Windows +// ConPTY path), so env is always nil. +func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { + id, err := handleID(handle) + if err != nil { + return nil, nil, err + } + return []string{r.binary, "attach-session", "-t", id}, nil, nil +} + +// run wraps runner.Run with a per-call timeout context. +func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { + cmdCtx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + out, err := r.runner.Run(cmdCtx, nil, r.binary, args...) + if cmdCtx.Err() != nil { + return out, cmdCtx.Err() + } + if err != nil { + return out, commandError{err: err, output: strings.TrimSpace(string(out))} + } + return out, nil +} + +// -- session name helpers -- + +func tmuxSessionName(id domain.SessionID) (string, error) { + raw := string(id) + if raw == "" { + return "", errors.New("tmux runtime: session id is required") + } + return SessionName(raw), nil +} + +// SessionName returns the tmux session name the runtime registers for a given +// session id, applying the same sanitisation Create does. Callers that print an +// attach hint must use this rather than the raw id. +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 handleID(handle ports.RuntimeHandle) (string, error) { + id := handle.ID + if id == "" { + return "", errors.New("tmux runtime: session id is required") + } + if !sessionIDPattern.MatchString(id) { + return "", fmt.Errorf("tmux runtime: invalid handle id %q", id) + } + return id, nil +} + +// -- output detection helpers -- + +// sessionMissingOutput reports whether a non-zero `tmux has-session` or +// `tmux kill-session` exit is definitively "session does not exist" rather +// than a transient probe failure. +func sessionMissingOutput(out string) bool { + s := strings.ToLower(out) + return strings.Contains(s, "can't find session") || + strings.Contains(s, "no server running") || + strings.Contains(s, "error connecting") || + strings.Contains(s, "session not found") +} + +// killSessionMissingOutput reports whether a non-zero `tmux kill-session` +// failed because the session was already gone. +func killSessionMissingOutput(out string) bool { + return sessionMissingOutput(out) +} + +// -- text helpers (ported from zellij) -- + +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, "") +} + +// -- env / quoting helpers -- + +func validateEnvKeys(env map[string]string) error { + for key := range env { + if !validEnvKey(key) { + return fmt.Errorf("tmux runtime: invalid env key %q", key) + } + } + return nil +} + +func validEnvKey(key string) bool { + if key == "" { + return false + } + for i, r := range key { + if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { + continue + } + if i > 0 && r >= '0' && r <= '9' { + continue + } + return false + } + return true +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + +// buildLaunchCommand builds the shell command string passed to `sh -c` (or the +// configured shell). It exports env vars, then runs argv, then execs a +// keep-alive interactive shell so the tmux session survives the agent exiting. +// +// ponytail: PATH handling matches the zellij unix path: PATH from cfg.Env is +// exported last, after all other keys, so an explicit override takes effect. +func buildLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { + path := cfg.Env["PATH"] + if path == "" { + path = getenv("PATH") + } + + var b strings.Builder + for _, key := range sortedKeys(cfg.Env) { + if key == "PATH" { + continue + } + b.WriteString("export ") + b.WriteString(key) + b.WriteString("=") + b.WriteString(shellQuote(cfg.Env[key])) + b.WriteString("; ") + } + if path != "" { + b.WriteString("export PATH=") + b.WriteString(shellQuote(path)) + b.WriteString("; ") + } + // Quote each argv word so spaces inside a word are preserved. + parts := make([]string, len(cfg.Argv)) + for i, a := range cfg.Argv { + parts[i] = shellQuote(a) + } + b.WriteString(strings.Join(parts, " ")) + // Keep the tmux session alive after the agent exits so the operator can + // inspect the terminal. The shell variable expansion picks up $SHELL from + // the process env if set, otherwise falls back to /bin/sh. + b.WriteString("; exec ${SHELL:-/bin/sh} -i") + return b.String() +} + +// -- error type -- + +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 } diff --git a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go new file mode 100644 index 00000000..7216fc41 --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go @@ -0,0 +1,141 @@ +package tmux + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestRuntimeIntegration(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux unavailable") + } + + ctx := context.Background() + id := "ao_itest_tmux" + r := New(Options{Timeout: 5 * time.Second}) + + // Ensure clean slate: ignore errors (session may not exist). + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) + + t.Cleanup(func() { + // Always destroy so a test failure never leaks a tmux session. + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) + }) + + h, err := r.Create(ctx, ports.RuntimeConfig{ + SessionID: "ao_itest_tmux", + WorkspacePath: t.TempDir(), + // Run a trivial command then drop into an interactive shell (the keep-alive + // exec is added by buildLaunchCommand, but we also verify here that output + // appears). + Argv: []string{"sh", "-c", "echo hello-from-tmux"}, + Env: map[string]string{"AO_SESSION_ID": id}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + alive, err := r.IsAlive(ctx, h) + if err != nil { + t.Fatalf("IsAlive: %v", err) + } + if !alive { + t.Fatal("alive = false, want true after create") + } + + // Wait for the echo output to appear (the session may take a moment to + // write it to the pane history). + out := waitForOutput(t, r, h, "hello-from-tmux", 5*time.Second) + if !strings.Contains(out, "hello-from-tmux") { + t.Fatalf("output = %q, want hello-from-tmux", out) + } + + // Send a command and verify it echoes back. + if err := r.SendMessage(ctx, h, "echo hello-send"); err != nil { + t.Fatalf("SendMessage: %v", err) + } + out = waitForOutput(t, r, h, "hello-send", 5*time.Second) + if !strings.Contains(out, "hello-send") { + t.Fatalf("output after SendMessage = %q, want hello-send", out) + } + + // Destroy and verify liveness goes false. + if err := r.Destroy(ctx, h); err != nil { + t.Fatalf("Destroy: %v", err) + } + alive, err = r.IsAlive(ctx, h) + if err != nil { + t.Fatalf("IsAlive after destroy: %v", err) + } + if alive { + t.Fatal("alive after destroy = true, want false") + } +} + +// TestRuntimeIntegrationExactSessionParsing verifies that IsAlive uses exact +// session matching and does not treat a prefix as a live session. +func TestRuntimeIntegrationExactSessionParsing(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux unavailable") + } + + ctx := context.Background() + longID := "ao_tmux_exact_long" + prefixID := "ao_tmux_exact" + + r := New(Options{Timeout: 5 * time.Second}) + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID}) + + t.Cleanup(func() { + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: longID}) + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: prefixID}) + }) + + h, err := r.Create(ctx, ports.RuntimeConfig{ + SessionID: "ao_tmux_exact_long", + WorkspacePath: t.TempDir(), + Argv: []string{"sh", "-c", "echo ready"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + // tmux has-session -t should NOT match because tmux + // requires the exact session name when using -t with a plain string (not a + // glob). Verify by probing the prefix handle directly. + prefixAlive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID}) + if err != nil { + // tmux may return an error (session not found) rather than exit 0. + // That is acceptable here: the point is the prefix must not be alive. + t.Logf("IsAlive prefix returned error (acceptable): %v", err) + } + if prefixAlive { + _ = r.Destroy(ctx, h) + t.Fatal("prefix handle reported alive; tmux session matching is not exact") + } +} + +// waitForOutput polls GetOutput until out contains want or the deadline passes. +func waitForOutput(t *testing.T, r *Runtime, h ports.RuntimeHandle, want string, deadline time.Duration) string { + t.Helper() + end := time.Now().Add(deadline) + var out string + for time.Now().Before(end) { + var err error + out, err = r.GetOutput(context.Background(), h, 50) + if err != nil { + t.Fatalf("GetOutput: %v", err) + } + if strings.Contains(out, want) { + return out + } + time.Sleep(100 * time.Millisecond) + } + return out +} diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go new file mode 100644 index 00000000..18c5e225 --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -0,0 +1,630 @@ +package tmux + +import ( + "context" + "errors" + "os/exec" + "reflect" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// -- fakeRunner test seam (mirrors zellij_test.go exactly) -- + +type fakeRunner struct { + calls []runnerCall + outputs [][]byte + err error +} + +type runnerCall struct { + env []string + name string + args []string +} + +func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) + var out []byte + if len(f.outputs) > 0 { + out = f.outputs[0] + f.outputs = f.outputs[1:] + } + if f.err != nil { + return out, f.err + } + return out, nil +} + +func (f *fakeRunner) Start(env []string, name string, args ...string) error { + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) + if len(f.outputs) > 0 { + f.outputs = f.outputs[1:] + } + return f.err +} + +// -- helpers -- + +func newTestRuntime(chunkSize int) (*Runtime, *fakeRunner) { + fr := &fakeRunner{} + r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/sh", ChunkSize: chunkSize}) + r.runner = fr + return r, fr +} + +// -- Options / New tests -- + +func TestNewDefaultsToPortableShell(t *testing.T) { + t.Setenv("SHELL", "") + r := New(Options{}) + if got := r.shell; got != "/bin/sh" { + t.Fatalf("default shell = %q, want /bin/sh", got) + } +} + +func TestNewPicksUpShellFromEnv(t *testing.T) { + t.Setenv("SHELL", "/bin/zsh") + r := New(Options{}) + if got := r.shell; got != "/bin/zsh" { + t.Fatalf("shell = %q, want /bin/zsh", got) + } +} + +// -- command builder tests -- + +func TestCommandBuilders(t *testing.T) { + if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/sh", "echo hi; exec ${SHELL:-/bin/sh} -i"), + []string{"new-session", "-d", "-s", "sess-1", "-x", "220", "-y", "50", "-c", "/tmp/ws", "/bin/sh", "-c", "echo hi; exec ${SHELL:-/bin/sh} -i"}; !reflect.DeepEqual(got, want) { + t.Fatalf("newSessionArgs = %#v, want %#v", got, want) + } + // set-option uses pane-targeting (no = prefix). + if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "sess-1", "status", "off"}; !reflect.DeepEqual(got, want) { + t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want) + } + // kill-session and has-session use exact-match prefix =. + if got, want := killSessionArgs("sess-1"), []string{"kill-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("killSessionArgs = %#v, want %#v", got, want) + } + if got, want := hasSessionArgs("sess-1"), []string{"has-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("hasSessionArgs = %#v, want %#v", got, want) + } + if got, want := sendKeysLiteralArgs("sess-1", "hello"), []string{"send-keys", "-t", "sess-1", "-l", "hello"}; !reflect.DeepEqual(got, want) { + t.Fatalf("sendKeysLiteralArgs = %#v, want %#v", got, want) + } + if got, want := sendEnterArgs("sess-1"), []string{"send-keys", "-t", "sess-1", "Enter"}; !reflect.DeepEqual(got, want) { + t.Fatalf("sendEnterArgs = %#v, want %#v", got, want) + } + if got, want := capturePaneArgs("sess-1", 10), []string{"capture-pane", "-t", "sess-1", "-p", "-S", "-10"}; !reflect.DeepEqual(got, want) { + t.Fatalf("capturePaneArgs = %#v, want %#v", got, want) + } +} + +// -- session name sanitization -- + +func TestSessionNameSanitizesSpecialChars(t *testing.T) { + got, err := tmuxSessionName("repo/issue#42.1") + if err != nil { + t.Fatalf("tmuxSessionName: %v", err) + } + if !sessionIDPattern.MatchString(got) { + t.Fatalf("sanitized id %q fails pattern", got) + } + if !strings.HasPrefix(got, "repo-issue-42-1-") { + t.Fatalf("sanitized id = %q, want readable prefix", got) + } + if got == "repo/issue#42.1" { + t.Fatal("sanitized id still contains raw unsafe characters") + } +} + +func TestSessionNamePassesThroughShortConforming(t *testing.T) { + if got := SessionName("myproj-1"); got != "myproj-1" { + t.Fatalf("SessionName = %q, want unchanged", got) + } +} + +func TestSessionNameMatchesCreateNaming(t *testing.T) { + long := domain.SessionID(strings.Repeat("x", 60) + "-1") + viaCreate, err := tmuxSessionName(long) + if err != nil { + t.Fatalf("tmuxSessionName: %v", err) + } + if got := SessionName(string(long)); got != viaCreate { + t.Fatalf("SessionName = %q, but Create uses %q", got, viaCreate) + } + if SessionName(string(long)) == string(long) { + t.Fatal("expected long id to be sanitised to a different name") + } +} + +// -- env key validation -- + +func TestCreateRejectsInvalidEnvKeys(t *testing.T) { + r, fr := newTestRuntime(0) + _ = fr + _, err := r.Create(context.Background(), ports.RuntimeConfig{ + SessionID: "sess-1", + WorkspacePath: "/tmp/ws", + Argv: []string{"echo", "hi"}, + Env: map[string]string{"BAD KEY": "x"}, + }) + if err == nil || !strings.Contains(err.Error(), "invalid env key") { + t.Fatalf("Create err = %v, want invalid env key", err) + } +} + +// -- Create tests -- + +func TestCreateIssuesNewSessionAndStatusOff(t *testing.T) { + // new-session output (ignored), set-option output, has-session output (exit 0 = alive) + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{nil, nil, nil} + + h, err := r.Create(context.Background(), ports.RuntimeConfig{ + SessionID: "sess-1", + WorkspacePath: "/tmp/ws", + Argv: []string{"echo", "hi"}, + Env: map[string]string{"AO_SESSION_ID": "sess-1"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if h.ID != "sess-1" { + t.Fatalf("handle ID = %q, want sess-1", h.ID) + } + // Expect 3 calls: new-session, set-option, has-session. + if len(fr.calls) != 3 { + t.Fatalf("calls = %d, want 3", len(fr.calls)) + } + + // Call 0: new-session + if got := fr.calls[0].args[0]; got != "new-session" { + t.Fatalf("call[0] = %q, want new-session", got) + } + // Check -s , -c are present. + joined := strings.Join(fr.calls[0].args, " ") + if !strings.Contains(joined, "-s sess-1") { + t.Fatalf("new-session args missing -s sess-1: %v", fr.calls[0].args) + } + if !strings.Contains(joined, "-c /tmp/ws") { + t.Fatalf("new-session args missing -c /tmp/ws: %v", fr.calls[0].args) + } + // Ensure -x and -y are set. + if !strings.Contains(joined, "-x 220") || !strings.Contains(joined, "-y 50") { + t.Fatalf("new-session args missing -x/-y: %v", fr.calls[0].args) + } + + // Call 1: set-option status off (plain target, pane-targeting does not use =). + if got, want := fr.calls[1].args, setStatusOffArgs("sess-1"); !reflect.DeepEqual(got, want) { + t.Fatalf("call[1] = %#v, want %#v", got, want) + } + + // Call 2: has-session (IsAlive, uses exact-match target =sess-1). + if got, want := fr.calls[2].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { + t.Fatalf("call[2] = %#v, want %#v", got, want) + } +} + +func TestCreateLaunchCommandContainsKeepAliveShell(t *testing.T) { + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{nil, nil, nil} + + _, err := r.Create(context.Background(), ports.RuntimeConfig{ + SessionID: "sess-1", + WorkspacePath: "/tmp/ws", + Argv: []string{"myagent", "--flag"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + // The launch command is the last argument to new-session (after shellPath -c). + args := fr.calls[0].args + launchCmd := args[len(args)-1] + if !strings.Contains(launchCmd, "exec ${SHELL:-/bin/sh} -i") { + t.Fatalf("launch command missing keep-alive shell: %q", launchCmd) + } + if !strings.Contains(launchCmd, "'myagent'") { + t.Fatalf("launch command missing quoted argv: %q", launchCmd) + } +} + +func TestCreateLaunchCommandExportsEnvVars(t *testing.T) { + oldGetenv := getenv + getenv = func(key string) string { + if key == "PATH" { + return "/usr/bin:/bin" + } + return "" + } + defer func() { getenv = oldGetenv }() + + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{nil, nil, nil} + + _, err := r.Create(context.Background(), ports.RuntimeConfig{ + SessionID: "sess-1", + WorkspacePath: "/tmp/ws", + Argv: []string{"myagent"}, + Env: map[string]string{ + "AO_SESSION_ID": "sess-1", + "ODD": "can't", + "PATH": "/custom/bin:/usr/bin", + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + args := fr.calls[0].args + launchCmd := args[len(args)-1] + for _, want := range []string{ + "export AO_SESSION_ID='sess-1';", + "export ODD='can'\\''t';", + "export PATH='/custom/bin:/usr/bin';", + } { + if !strings.Contains(launchCmd, want) { + t.Fatalf("launch command missing %q in: %q", want, launchCmd) + } + } +} + +func TestCreateDestroysAndReturnsErrorWhenNotAlive(t *testing.T) { + r, fr := newTestRuntime(0) + // new-session ok, set-option ok, has-session fails (not alive) + fr.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} + fr.err = nil + // Override: make has-session return ExitError. + // We need has-session to fail to simulate the session not being alive. + // Use a different approach: make the third call return an ExitError. + r2, fr2 := newTestRuntime(0) + fr2.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} + // We need the has-session to return exitErr + the output. But fakeRunner + // returns the same err for all calls. Set a custom err only for the third call + // by using a custom fake. + _ = r + _ = fr + + // Use a specialized fakeRunner that returns an exit error only for the 3rd call. + fr3 := &fakeRunnerSelectiveErr{exitErrAt: 2} + fr3.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} + r2.runner = fr3 + + _, err := r2.Create(context.Background(), ports.RuntimeConfig{ + SessionID: "sess-1", + WorkspacePath: "/tmp/ws", + Argv: []string{"myagent"}, + }) + if err == nil { + t.Fatal("Create: got nil, want error when session not alive after create") + } + // Verify Destroy was called (kill-session). + hasKill := false + for _, c := range fr3.calls { + if len(c.args) > 0 && c.args[0] == "kill-session" { + hasKill = true + } + } + if !hasKill { + t.Fatal("expected kill-session cleanup call when session not alive") + } +} + +// fakeRunnerSelectiveErr returns an exec.ExitError for the call at index exitErrAt. +type fakeRunnerSelectiveErr struct { + calls []runnerCall + outputs [][]byte + exitErrAt int +} + +func (f *fakeRunnerSelectiveErr) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { + idx := len(f.calls) + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) + var out []byte + if len(f.outputs) > 0 { + out = f.outputs[0] + f.outputs = f.outputs[1:] + } + if idx == f.exitErrAt { + return out, &exec.ExitError{} + } + return out, nil +} + +func (f *fakeRunnerSelectiveErr) Start(env []string, name string, args ...string) error { + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) + if len(f.outputs) > 0 { + f.outputs = f.outputs[1:] + } + return nil +} + +// -- Destroy tests -- + +func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{[]byte("can't find session: sess-1")} + fr.err = &exec.ExitError{} + + if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { + t.Fatalf("Destroy: %v", err) + } + if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" { + t.Fatalf("calls = %#v, want only kill-session", fr.calls) + } +} + +func TestDestroyIsIdempotentWhenNoServer(t *testing.T) { + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{[]byte("no server running on /tmp/tmux-1000/default")} + fr.err = &exec.ExitError{} + + if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { + t.Fatalf("Destroy no-server: %v", err) + } +} + +func TestDestroyReportsUnexpectedFailures(t *testing.T) { + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{[]byte("permission denied")} + fr.err = &exec.ExitError{} + + if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err == nil { + t.Fatal("Destroy: got nil, want unexpected failure error") + } +} + +func TestDestroyArgs(t *testing.T) { + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{nil} + + if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { + t.Fatalf("Destroy: %v", err) + } + // killSessionArgs uses exact-match target =. + if got, want := fr.calls[0].args, killSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { + t.Fatalf("destroy args = %#v, want %#v", got, want) + } +} + +// -- IsAlive tests -- + +func TestIsAliveReturnsTrueOnExitZero(t *testing.T) { + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{nil} + + alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) + if err != nil { + t.Fatalf("IsAlive: %v", err) + } + if !alive { + t.Fatal("alive = false, want true") + } + if got, want := fr.calls[0].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { + t.Fatalf("has-session args = %#v, want %#v", got, want) + } +} + +func TestIsAliveReturnsFalseNilOnCantFindSession(t *testing.T) { + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{[]byte("can't find session: sess-1")} + fr.err = &exec.ExitError{} + + alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) + if err != nil { + t.Fatalf("IsAlive: %v", err) + } + if alive { + t.Fatal("alive = true, want false") + } +} + +func TestIsAliveReturnsFalseNilOnNoServer(t *testing.T) { + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{[]byte("no server running on /tmp/tmux-1000/default")} + fr.err = &exec.ExitError{} + + alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) + if err != nil { + t.Fatalf("IsAlive: %v", err) + } + if alive { + t.Fatal("alive = true, want false") + } +} + +func TestIsAliveReturnsFalseNilOnErrorConnecting(t *testing.T) { + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{[]byte("error connecting to /tmp/tmux-1000/default (No such file or directory)")} + fr.err = &exec.ExitError{} + + alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) + if err != nil { + t.Fatalf("IsAlive error connecting: %v", err) + } + if alive { + t.Fatal("alive = true, want false") + } +} + +// IsAlive must treat any non-"missing" non-zero exit as a probe error so the +// reaper never reads a transient failure as proof of death. +func TestIsAliveReportsOtherExitFailuresAsProbeErrors(t *testing.T) { + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{[]byte("unexpected internal error")} + fr.err = &exec.ExitError{} + + alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) + if err == nil { + t.Fatal("IsAlive: got nil, want probe error — failed probe must not read as dead") + } + if alive { + t.Fatal("alive = true on probe failure") + } +} + +// -- SendMessage tests -- + +func TestSendMessageChunksAndSendsEnter(t *testing.T) { + r, fr := newTestRuntime(5) // chunkSize=5 + // "hello世界": hello=5 bytes, 世=3 bytes, 界=3 bytes => 3 sends + 1 Enter + if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, "hello世界"); err != nil { + t.Fatalf("SendMessage: %v", err) + } + if len(fr.calls) != 4 { + t.Fatalf("calls = %d, want 4 (3 chunks + Enter)", len(fr.calls)) + } + if got, want := fr.calls[0].args, sendKeysLiteralArgs("sess-1", "hello"); !reflect.DeepEqual(got, want) { + t.Fatalf("chunk 1 args = %#v, want %#v", got, want) + } + if got, want := fr.calls[1].args, sendKeysLiteralArgs("sess-1", "世"); !reflect.DeepEqual(got, want) { + t.Fatalf("chunk 2 args = %#v, want %#v", got, want) + } + if got, want := fr.calls[2].args, sendKeysLiteralArgs("sess-1", "界"); !reflect.DeepEqual(got, want) { + t.Fatalf("chunk 3 args = %#v, want %#v", got, want) + } + if got, want := fr.calls[3].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) { + t.Fatalf("Enter args = %#v, want %#v", got, want) + } +} + +func TestSendMessageUsesLiteralFlag(t *testing.T) { + r, fr := newTestRuntime(0) + if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, "Enter"); err != nil { + t.Fatalf("SendMessage: %v", err) + } + // First call must use -l so "Enter" is sent literally, not as a key binding. + if fr.calls[0].args[3] != "-l" { + t.Fatalf("send-keys args[3] = %q, want -l", fr.calls[0].args[3]) + } +} + +// -- GetOutput tests -- + +func TestGetOutputValidatesLines(t *testing.T) { + r, _ := newTestRuntime(0) + _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 0) + if err == nil { + t.Fatal("GetOutput lines=0: got nil, want error") + } +} + +func TestGetOutputTrimsLines(t *testing.T) { + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{[]byte("one\ntwo\nthree\n")} + + out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 2) + if err != nil { + t.Fatalf("GetOutput: %v", err) + } + if out != "two\nthree\n" { + t.Fatalf("output = %q, want last two lines", out) + } +} + +func TestGetOutputTrimsTrailingScreenPaddingBeforeTailing(t *testing.T) { + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{[]byte("ready\nprompt> echo hi\nhi\n\n\n\n")} + + out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 2) + if err != nil { + t.Fatalf("GetOutput: %v", err) + } + if out != "prompt> echo hi\nhi\n" { + t.Fatalf("output = %q, want last non-padding lines", out) + } +} + +func TestGetOutputArgs(t *testing.T) { + r, fr := newTestRuntime(0) + fr.outputs = [][]byte{[]byte("output\n")} + + _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 10) + if err != nil { + t.Fatalf("GetOutput: %v", err) + } + if got, want := fr.calls[0].args, capturePaneArgs("sess-1", 10); !reflect.DeepEqual(got, want) { + t.Fatalf("capture-pane args = %#v, want %#v", got, want) + } +} + +// -- AttachCommand tests -- + +func TestAttachCommandReturnsExpectedArgv(t *testing.T) { + r := New(Options{Binary: "/usr/bin/tmux", Timeout: time.Second}) + argv, env, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1"}) + if err != nil { + t.Fatalf("AttachCommand: %v", err) + } + want := []string{"/usr/bin/tmux", "attach-session", "-t", "sess-1"} + if !reflect.DeepEqual(argv, want) { + t.Fatalf("argv = %#v, want %#v", argv, want) + } + if env != nil { + t.Fatalf("env = %#v, want nil", env) + } +} + +func TestAttachCommandRejectsInvalidHandle(t *testing.T) { + r := New(Options{}) + _, _, err := r.AttachCommand(ports.RuntimeHandle{ID: ""}) + if err == nil { + t.Fatal("AttachCommand empty handle: got nil, want error") + } +} + +// -- commandError tests -- + +func TestCommandErrorUnwraps(t *testing.T) { + base := errors.New("base") + err := commandError{err: base, output: "details"} + if !errors.Is(err, base) { + t.Fatal("commandError should unwrap base error") + } + if !strings.Contains(err.Error(), "details") { + t.Fatalf("error = %q, want output details", err.Error()) + } +} + +// -- text helper tests -- + +func TestChunks(t *testing.T) { + if got := chunks("", 5); !reflect.DeepEqual(got, []string{""}){ + t.Fatalf("chunks empty = %#v", got) + } + if got := chunks("hello", 10); !reflect.DeepEqual(got, []string{"hello"}) { + t.Fatalf("chunks fits = %#v", got) + } + // UTF-8 boundary: 世 is 3 bytes; with chunkSize=5 "hello世界" splits at 5,6,6 + got := chunks("hello世界", 5) + if len(got) != 3 { + t.Fatalf("chunks count = %d, want 3: %#v", len(got), got) + } + if got[0] != "hello" || got[1] != "世" || got[2] != "界" { + t.Fatalf("chunks = %#v, want [hello 世 界]", got) + } +} + +func TestTailLines(t *testing.T) { + if got := tailLines("a\nb\nc\n", 2); got != "b\nc\n" { + t.Fatalf("tailLines = %q, want b/c", got) + } + if got := tailLines("a\nb\n", 5); got != "a\nb\n" { + t.Fatalf("tailLines fewer = %q", got) + } + if got := tailLines("", 5); got != "" { + t.Fatalf("tailLines empty = %q", got) + } +} + +func TestTrimTrailingBlankLines(t *testing.T) { + if got := trimTrailingBlankLines("a\nb\n\n\n"); got != "a\nb\n" { + t.Fatalf("trimTrailingBlankLines = %q, want a/b", got) + } + if got := trimTrailingBlankLines(""); got != "" { + t.Fatalf("trimTrailingBlankLines empty = %q", got) + } +} From 44c3e61f239fd1184d9063debbd73cf65a11f68f Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 23 Jun 2026 21:50:17 +0530 Subject: [PATCH 02/60] fix(tmux): address four code-review findings in tmux runtime adapter - Remove em dash from tmux_test.go:462 (project hard rule); replace with semicolon - Derive integration test session IDs from t.Name() so concurrent runs do not collide on the same tmux session - Remove dead scaffolding variables (r/fr, r2/fr2) in TestCreateDestroysAndReturnsErrorWhenNotAlive - Quote \${SHELL:-/bin/sh} in buildLaunchCommand and update all asserting tests Co-Authored-By: Claude Opus 4.8 --- .../internal/adapters/runtime/tmux/tmux.go | 2 +- .../runtime/tmux/tmux_integration_test.go | 12 ++++++---- .../adapters/runtime/tmux/tmux_test.go | 24 ++++--------------- 3 files changed, 13 insertions(+), 25 deletions(-) diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go index 3b477456..be6e2fde 100644 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -461,7 +461,7 @@ func buildLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { // Keep the tmux session alive after the agent exits so the operator can // inspect the terminal. The shell variable expansion picks up $SHELL from // the process env if set, otherwise falls back to /bin/sh. - b.WriteString("; exec ${SHELL:-/bin/sh} -i") + b.WriteString(`; exec "${SHELL:-/bin/sh}" -i`) return b.String() } diff --git a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go index 7216fc41..1f491f8d 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -16,7 +17,7 @@ func TestRuntimeIntegration(t *testing.T) { } ctx := context.Background() - id := "ao_itest_tmux" + id := strings.ReplaceAll(t.Name(), "/", "_") r := New(Options{Timeout: 5 * time.Second}) // Ensure clean slate: ignore errors (session may not exist). @@ -28,7 +29,7 @@ func TestRuntimeIntegration(t *testing.T) { }) h, err := r.Create(ctx, ports.RuntimeConfig{ - SessionID: "ao_itest_tmux", + SessionID: domain.SessionID(id), WorkspacePath: t.TempDir(), // Run a trivial command then drop into an interactive shell (the keep-alive // exec is added by buildLaunchCommand, but we also verify here that output @@ -85,8 +86,9 @@ func TestRuntimeIntegrationExactSessionParsing(t *testing.T) { } ctx := context.Background() - longID := "ao_tmux_exact_long" - prefixID := "ao_tmux_exact" + base := strings.ReplaceAll(t.Name(), "/", "_") + longID := base + "_long" + prefixID := base r := New(Options{Timeout: 5 * time.Second}) _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) @@ -98,7 +100,7 @@ func TestRuntimeIntegrationExactSessionParsing(t *testing.T) { }) h, err := r.Create(ctx, ports.RuntimeConfig{ - SessionID: "ao_tmux_exact_long", + SessionID: domain.SessionID(longID), WorkspacePath: t.TempDir(), Argv: []string{"sh", "-c", "echo ready"}, }) diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go index 18c5e225..72b165b9 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -78,8 +78,8 @@ func TestNewPicksUpShellFromEnv(t *testing.T) { // -- command builder tests -- func TestCommandBuilders(t *testing.T) { - if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/sh", "echo hi; exec ${SHELL:-/bin/sh} -i"), - []string{"new-session", "-d", "-s", "sess-1", "-x", "220", "-y", "50", "-c", "/tmp/ws", "/bin/sh", "-c", "echo hi; exec ${SHELL:-/bin/sh} -i"}; !reflect.DeepEqual(got, want) { + if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/sh", `echo hi; exec "${SHELL:-/bin/sh}" -i`), + []string{"new-session", "-d", "-s", "sess-1", "-x", "220", "-y", "50", "-c", "/tmp/ws", "/bin/sh", "-c", `echo hi; exec "${SHELL:-/bin/sh}" -i`}; !reflect.DeepEqual(got, want) { t.Fatalf("newSessionArgs = %#v, want %#v", got, want) } // set-option uses pane-targeting (no = prefix). @@ -225,7 +225,7 @@ func TestCreateLaunchCommandContainsKeepAliveShell(t *testing.T) { // The launch command is the last argument to new-session (after shellPath -c). args := fr.calls[0].args launchCmd := args[len(args)-1] - if !strings.Contains(launchCmd, "exec ${SHELL:-/bin/sh} -i") { + if !strings.Contains(launchCmd, `exec "${SHELL:-/bin/sh}" -i`) { t.Fatalf("launch command missing keep-alive shell: %q", launchCmd) } if !strings.Contains(launchCmd, "'myagent'") { @@ -273,22 +273,8 @@ func TestCreateLaunchCommandExportsEnvVars(t *testing.T) { } func TestCreateDestroysAndReturnsErrorWhenNotAlive(t *testing.T) { - r, fr := newTestRuntime(0) - // new-session ok, set-option ok, has-session fails (not alive) - fr.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} - fr.err = nil - // Override: make has-session return ExitError. - // We need has-session to fail to simulate the session not being alive. - // Use a different approach: make the third call return an ExitError. - r2, fr2 := newTestRuntime(0) - fr2.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} - // We need the has-session to return exitErr + the output. But fakeRunner - // returns the same err for all calls. Set a custom err only for the third call - // by using a custom fake. - _ = r - _ = fr - // Use a specialized fakeRunner that returns an exit error only for the 3rd call. + r2, _ := newTestRuntime(0) fr3 := &fakeRunnerSelectiveErr{exitErrAt: 2} fr3.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} r2.runner = fr3 @@ -459,7 +445,7 @@ func TestIsAliveReportsOtherExitFailuresAsProbeErrors(t *testing.T) { alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) if err == nil { - t.Fatal("IsAlive: got nil, want probe error — failed probe must not read as dead") + t.Fatal("IsAlive: got nil, want probe error; failed probe must not read as dead") } if alive { t.Fatal("alive = true on probe failure") From b459abfd565e0aa1cd9bc1ee9e30b6ff80ca76cb Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 23 Jun 2026 21:59:16 +0530 Subject: [PATCH 03/60] feat(runtime): wire tmux on Darwin/Linux via runtimeselect, keep zellij on Windows - New package runtimeselect: Runtime union interface (ports.Runtime + SendMessage/GetOutput/AttachCommand) with compile-time assertions for both adapters. New(log) returns tmux on non-Windows, zellij on Windows (replicating the old daemon socket-dir setup). - daemon.go: replace zellij-specific socket-dir block with runtimeselect.New(log); update comment to be runtime-neutral. - lifecycle_wiring.go: startSession param changed from *zellij.Runtime to runtimeselect.Runtime. - cli/doctor.go: runtime-aware checkTerminalRuntime (tmux on Darwin/Linux, zellij on Windows); added checkTmux. - cli/spawn.go: attach hint prints tmux attach -t on non-Windows, keeps zellij attach hint on Windows. - wiring_test.go: startSession test uses runtimeselect.New(nil); zellij direct tests retained for zellij-specific coverage. - doctor_test.go: replaced three zellij tool tests with tmux equivalents. Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/task-2-report.md | 95 +++++++++++++++++++ .../runtime/runtimeselect/runtimeselect.go | 46 +++++++++ backend/internal/cli/doctor.go | 31 +++++- backend/internal/cli/doctor_test.go | 38 ++++---- backend/internal/cli/spawn.go | 21 ++-- backend/internal/daemon/daemon.go | 20 +--- backend/internal/daemon/lifecycle_wiring.go | 6 +- backend/internal/daemon/wiring_test.go | 7 +- 8 files changed, 216 insertions(+), 48 deletions(-) create mode 100644 .superpowers/sdd/task-2-report.md create mode 100644 backend/internal/adapters/runtime/runtimeselect/runtimeselect.go diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md new file mode 100644 index 00000000..2a841763 --- /dev/null +++ b/.superpowers/sdd/task-2-report.md @@ -0,0 +1,95 @@ +# Task 2 Report: runtime selection + daemon wiring + doctor/spawn hints + +## Status: DONE + +## Files Changed + +### New file +- `backend/internal/adapters/runtime/runtimeselect/runtimeselect.go` + - Defines the `Runtime` union interface (embeds `ports.Runtime`; adds `SendMessage`, `GetOutput`, `AttachCommand`). + - `New(log *slog.Logger) Runtime` returns `tmux.New(tmux.Options{})` on non-Windows, and replicates the old daemon zellij socket-dir setup on Windows. + - Compile-time assertions: `var _ Runtime = (*tmux.Runtime)(nil)` and `var _ Runtime = (*zellij.Runtime)(nil)`. + +### Modified files + +**`backend/internal/daemon/daemon.go`** +- Swapped `zellij` import for `runtimeselect`. +- Replaced the 10-line zellij socket-dir block with `runtimeAdapter := runtimeselect.New(log)`. +- Updated the terminal-streaming comment to be runtime-neutral. +- `os` import retained (still used by `newLogger()` for `os.Stderr`). + +**`backend/internal/daemon/lifecycle_wiring.go`** +- Swapped `zellij` import for `runtimeselect`. +- Changed `startSession` param from `runtime *zellij.Runtime` to `runtime runtimeselect.Runtime`. +- Updated doc comment from "over the real zellij runtime" to "over the selected runtime". + +**`backend/internal/cli/doctor.go`** +- Added `"runtime"` stdlib import. +- Renamed call site from `c.checkZellij(ctx)` to `c.checkTerminalRuntime(ctx)`. +- Added `checkTerminalRuntime` dispatcher: calls `checkTmux` on non-Windows, `checkZellij` on Windows. +- Added `checkTmux`: resolves `tmux` via `LookPath`, runs `tmux -V`, reports PASS with version string; WARN for missing (matching the zellij missing-level so end-to-end `ok: true` tests remain green), FAIL if found but version command errors. +- Kept `checkZellij` intact (Windows still uses it). + +**`backend/internal/cli/spawn.go`** +- Added `"runtime"` stdlib import and `tmux` adapter import. +- Replaced unconditional `zellij attach` hint with a runtime-aware block: + - Non-Windows: `tmux attach -t ` using `tmux.SessionName(res.Session.ID)`. + - Windows: existing `ZELLIJ_SOCKET_DIR=... zellij attach ` hint (unchanged). + +**`backend/internal/daemon/wiring_test.go`** +- Added `runtimeselect` import. +- `TestWiring_StartSessionBuildsSessionService`: replaced `zellij.New(zellij.Options{})` with `runtimeselect.New(nil)` so the test compiles and passes after the `startSession` signature change. +- `TestWiring_StartLifecycleThreadsMessengerIntoLCM` and `TestDaemonZellijSocketDir_LeavesBudgetForSessionNames` still use `zellij` directly; those sub-tests are testing zellij semantics that still exist and were not changed. + +**`backend/internal/cli/doctor_test.go`** +- Renamed and rewrote the three zellij-specific tool tests to cover `checkTmux` (on Darwin): + - `TestDoctorChecksZellijVersion` -> `TestDoctorChecksTmuxVersion` + - `TestDoctorFailsUnsupportedZellijVersion` -> `TestDoctorChecksTmuxVersionFailsOnError` + - `TestDoctorWarnsWhenZellijMissing` -> `TestDoctorWarnsWhenTmuxMissing` + +## Union Interface + +```go +type Runtime interface { + ports.Runtime // Create, Destroy, IsAlive + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error + GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) + AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) +} +``` + +## SessionName export + +`tmux.SessionName(id string) string` was already exported in Task 1 (line 267 of `tmux.go`). No change needed. + +## Handling of wiring_test.go + +The only test that called `startSession` with a concrete `*zellij.Runtime` was `TestWiring_StartSessionBuildsSessionService`. It now passes `runtimeselect.New(nil)` (which returns the tmux adapter on Darwin). Tests that still use `zellij.New` directly (`startLifecycle` call and `DefaultSocketDir` assertion) were left untouched; they test zellij semantics and compile fine because the zellij import remains. + +## Verification Outputs + +All run from `backend/`. + +### `go build ./...` +``` +Go build: Success +``` + +### `GOOS=windows go build ./...` +``` +Go build: Success +``` + +### `go test ./...` +``` +Go test: 1585 passed in 75 packages +``` + +### `go vet ./internal/daemon/... ./internal/cli/... ./internal/adapters/runtime/...` +``` +Go vet: No issues found +``` + +## Concerns + +None. All checks green, no pre-existing failures, no new dependencies, go.mod unchanged. diff --git a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go new file mode 100644 index 00000000..e6ca4a04 --- /dev/null +++ b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go @@ -0,0 +1,46 @@ +// Package runtimeselect picks the correct runtime backend by platform: +// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). +package runtimeselect + +import ( + "context" + "log/slog" + "os" + "runtime" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Runtime is the union interface that both tmux and zellij satisfy. +// It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods +// the daemon wires directly. +type Runtime interface { + ports.Runtime // Create, Destroy, IsAlive + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error + GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) + AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) +} + +// Compile-time assertions: both adapters must implement the union interface. +var _ Runtime = (*tmux.Runtime)(nil) +var _ Runtime = (*zellij.Runtime)(nil) + +// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. +// log is used only for the Windows zellij socket-dir warning; nil is safe. +func New(log *slog.Logger) Runtime { + if runtime.GOOS != "windows" { + return tmux.New(tmux.Options{}) + } + // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. + dir := zellij.DefaultSocketDir() + if dir != "" { + if err := os.MkdirAll(dir, 0o700); err != nil { + if log != nil { + log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) + } + } + } + return zellij.New(zellij.Options{SocketDir: dir}) +} diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go index 60b0e296..c6def22b 100644 --- a/backend/internal/cli/doctor.go +++ b/backend/internal/cli/doctor.go @@ -17,6 +17,8 @@ import ( "github.com/spf13/cobra" + "runtime" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" "github.com/aoagents/agent-orchestrator/backend/internal/config" @@ -168,7 +170,7 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { checks = append(checks, c.checkGit(ctx), - c.checkZellij(ctx), + c.checkTerminalRuntime(ctx), c.checkAOBinary(), ) for _, harness := range doctorHarnesses { @@ -290,6 +292,33 @@ func (c *commandContext) checkGit(ctx context.Context) doctorCheck { return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; supports worktrees)", path, version)} } +// checkTerminalRuntime checks for the runtime multiplexer used on this platform: +// tmux on Darwin/Linux, zellij on Windows. +func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { + if runtime.GOOS == "windows" { + return c.checkZellij(ctx) + } + return c.checkTmux(ctx) +} + +func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { + path, err := c.deps.LookPath("tmux") + if err != nil || path == "" { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"} + } + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + out, err := c.deps.CommandOutput(reqCtx, path, "-V") + if err != nil { + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s: %v", path, err)} + } + version := firstOutputLine(out) + if version == "" { + version = "version unknown" + } + return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s)", path, version)} +} + func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { path, err := c.deps.LookPath("zellij") if err != nil || path == "" { diff --git a/backend/internal/cli/doctor_test.go b/backend/internal/cli/doctor_test.go index c1bf1694..2e4acf07 100644 --- a/backend/internal/cli/doctor_test.go +++ b/backend/internal/cli/doctor_test.go @@ -52,53 +52,55 @@ func TestDoctorFailsWhenGitMissing(t *testing.T) { } } -func TestDoctorChecksZellijVersion(t *testing.T) { +func TestDoctorChecksTmuxVersion(t *testing.T) { setConfigEnv(t) - c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, args ...string) ([]byte, error) { + c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, args ...string) ([]byte, error) { switch name { case "/bin/git": return []byte("git version 2.43.0\n"), nil - case "/bin/zellij": - if len(args) != 1 || args[0] != "--version" { - t.Fatalf("unexpected zellij command: %s %v", name, args) + case "/bin/tmux": + if len(args) != 1 || args[0] != "-V" { + t.Fatalf("unexpected tmux command: %s %v", name, args) } - return []byte("zellij 0.44.3\n"), nil + return []byte("tmux 3.3a\n"), nil default: t.Fatalf("unexpected command: %s %v", name, args) return nil, nil } }) - check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") - if check.Level != doctorPass || !strings.Contains(check.Message, "0.44.3") { - t.Fatalf("zellij check = %+v, want PASS with version", check) + check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") + if check.Level != doctorPass || !strings.Contains(check.Message, "3.3a") { + t.Fatalf("tmux check = %+v, want PASS with version", check) } } -func TestDoctorFailsUnsupportedZellijVersion(t *testing.T) { +// TestDoctorChecksTmuxVersionFailsOnError covers the case where tmux is found +// but the version command fails. +func TestDoctorChecksTmuxVersionFailsOnError(t *testing.T) { setConfigEnv(t) - c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { + c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { if name == "/bin/git" { return []byte("git version 2.43.0\n"), nil } - return []byte("zellij 0.44.2\n"), nil + return nil, errors.New("exec: tmux: not found") }) - check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") - if check.Level != doctorFail || !strings.Contains(check.Message, "require >= 0.44.3") { - t.Fatalf("zellij check = %+v, want FAIL with minimum version", check) + check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") + if check.Level != doctorFail { + t.Fatalf("tmux check = %+v, want FAIL on version error", check) } } -func TestDoctorWarnsWhenZellijMissing(t *testing.T) { +func TestDoctorWarnsWhenTmuxMissing(t *testing.T) { setConfigEnv(t) c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { return []byte("git version 2.43.0\n"), nil }) - check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") + check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") if check.Level != doctorWarn { - t.Fatalf("zellij check = %+v, want WARN", check) + t.Fatalf("tmux check = %+v, want WARN", check) } } diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go index b5cc7174..ae7bac4c 100644 --- a/backend/internal/cli/spawn.go +++ b/backend/internal/cli/spawn.go @@ -4,10 +4,12 @@ import ( "context" "fmt" "net/url" + "runtime" "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" ) @@ -97,14 +99,17 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { if _, err := fmt.Fprintf(out, "spawned session %s (%s)%s\n", res.Session.ID, res.Session.Status, claimLabel); err != nil { return err } - // The daemon runs zellij under a short, non-default socket dir (see - // zellij.DefaultSocketDir), so a plain `zellij attach` wouldn't find - // the session — prefix the env so the hint is copy-pasteable. Use the - // sanitised name zellij actually registers (zellij.SessionName): a long - // session id maps to a different name than the raw id. - attach := fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) - if dir := zellij.DefaultSocketDir(); dir != "" { - attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) + // Print a copy-pasteable attach hint for the selected runtime. + // On Darwin/Linux: tmux attach-session using the sanitised session name. + // On Windows: zellij attach with the short socket dir prefix. + var attach string + if runtime.GOOS != "windows" { + attach = fmt.Sprintf("tmux attach -t %s", tmux.SessionName(res.Session.ID)) + } else { + attach = fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) + if dir := zellij.DefaultSocketDir(); dir != "" { + attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) + } } _, err := fmt.Fprintf(out, "attach with: %s\n", attach) return err diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 59791c86..2bdab641 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -13,7 +13,7 @@ import ( "syscall" "time" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/notify" @@ -82,21 +82,11 @@ func Run() error { return err } - // Terminal streaming: the Zellij runtime supplies the PTY-attach command and - // liveness; the CDC broadcaster feeds the session-state channel. The manager + // Terminal streaming: the selected runtime (tmux on macOS/Linux, zellij on Windows) supplies the + // PTY-attach command and liveness; the CDC broadcaster feeds the session-state channel. The manager // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow - // through the CDC change_log — only session-state events do. - // zellij's default socket dir is too long on macOS for long session ids - // (see zellij.DefaultSocketDir); use a short, stable one and ensure it exists. - zellijSocketDir := zellij.DefaultSocketDir() - if zellijSocketDir != "" { - if err := os.MkdirAll(zellijSocketDir, 0o700); err != nil { - // Don't abort startup, but surface it: every spawn's zellij session - // would otherwise fail later with an opaque socket-bind error. - log.Warn("could not create zellij socket dir; spawns may fail", "dir", zellijSocketDir, "error", err) - } - } - runtimeAdapter := zellij.New(zellij.Options{SocketDir: zellijSocketDir}) + // through the CDC change_log -- only session-state events do. + runtimeAdapter := runtimeselect.New(log) termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) defer termMgr.Close() diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 3bf5ff7d..5d211600 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -10,7 +10,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitydispatch" agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/reviewer" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -59,10 +59,10 @@ func (l *lifecycleStack) Stop() { } // startSession builds the controller-facing session service: a session manager -// over the real zellij runtime, a per-session gitworktree workspace, the shared +// over the selected runtime, a per-session gitworktree workspace, the shared // store + LCM, the per-session agent resolver, and the agent messenger. The // returned service is mounted at httpd APIDeps.Sessions. -func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { +func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { defaultAgent := cfg.Agent if defaultAgent == "" { defaultAgent = config.DefaultAgent diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index a3acd553..21bd248d 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" telemetryadapter "github.com/aoagents/agent-orchestrator/backend/internal/adapters/telemetry" "github.com/aoagents/agent-orchestrator/backend/internal/cdc" @@ -149,9 +150,9 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { lcm := lifecycle.New(store, nil) cfg := config.Config{DataDir: t.TempDir()} - runtime := zellij.New(zellij.Options{}) - messenger := newSessionMessenger(store, runtime, log) - svc, reviewSvc, err := startSession(cfg, runtime, store, lcm, messenger, telemetryadapter.NoopSink{}, log) + rt := runtimeselect.New(nil) + messenger := newSessionMessenger(store, rt, log) + svc, reviewSvc, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) if err != nil { t.Fatalf("startSession: %v", err) } From 4b21e4b8a522d036fbb08c230820a889b2490448 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 23 Jun 2026 22:03:34 +0530 Subject: [PATCH 04/60] chore: tidy runtime-neutral comments and doctor import grouping Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/progress.md | 17 + .superpowers/sdd/task-1-report.md | 64 + .superpowers/sdd/task-1-review-package.txt | 2092 ++++++++++++++++++++ .superpowers/sdd/task-2-brief.md | 142 ++ .superpowers/sdd/task-2-review-package.txt | 1069 ++++++++++ backend/internal/cli/doctor.go | 3 +- backend/internal/daemon/daemon.go | 4 +- 7 files changed, 3387 insertions(+), 4 deletions(-) create mode 100644 .superpowers/sdd/progress.md create mode 100644 .superpowers/sdd/task-1-review-package.txt create mode 100644 .superpowers/sdd/task-2-brief.md create mode 100644 .superpowers/sdd/task-2-review-package.txt diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md new file mode 100644 index 00000000..cf470eff --- /dev/null +++ b/.superpowers/sdd/progress.md @@ -0,0 +1,17 @@ +# Migration: Zellij -> tmux (Darwin/Linux) + ConPTY (Windows) + +Branch: migrate-zellij-to-tmux-conpty +Base commit: e970f72 + +## Phase A (Darwin/Linux, fully testable here) +- Task 1: tmux runtime adapter (adapters/runtime/tmux/) — COMPLETE +- Task 2: runtime selection + daemon wiring + doctor/spawn hints — IN PROGRESS + +## Ledger +Task 1: complete (commits e970f72..44c3e61, review clean — spec PASS, 34 tests green, no em dashes) + +## Phase B (Windows, needs a Windows box to verify) — DEFERRED +- In-process ConPTY runtime + Attacher/Stream interface change + delete zellij + +## Ledger +(append "Task N: complete (commits .., review clean)" as tasks finish) diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md index 08dc462c..2fb994c2 100644 --- a/.superpowers/sdd/task-1-report.md +++ b/.superpowers/sdd/task-1-report.md @@ -63,3 +63,67 @@ $ go vet ./internal/adapters/runtime/tmux/... None. The build is green, all 34 tests pass (including the integration tests on the installed tmux 3.6b), and no files outside the new package were modified. + +--- + +# Code-Review Fix Report (commit 44c3e61) + +## What was fixed + +Four findings from code review, all scoped to `backend/internal/adapters/runtime/tmux/`. + +### Finding 1: Em dash removed (hard project rule) + +File: `tmux_test.go:462` + +Replaced the em dash character in the fatal message with a semicolon: +- Before: `"IsAlive: got nil, want probe error -- failed probe must not read as dead"` (with actual em-dash Unicode) +- After: `"IsAlive: got nil, want probe error; failed probe must not read as dead"` + +A full package grep confirmed zero remaining em dashes. + +### Finding 2: Integration test session IDs derived from t.Name() + +File: `tmux_integration_test.go` + +Both integration tests previously used hardcoded session IDs that would collide under `-count=2` or parallel runs: +- `TestRuntimeIntegration`: was `"ao_itest_tmux"`, now `strings.ReplaceAll(t.Name(), "/", "_")` +- `TestRuntimeIntegrationExactSessionParsing`: was `"ao_tmux_exact_long"` / `"ao_tmux_exact"`, now `base + "_long"` / `base` where `base = strings.ReplaceAll(t.Name(), "/", "_")` + +Added `domain` import to support the `domain.SessionID(id)` conversion in `RuntimeConfig.SessionID`. + +Existing `t.Cleanup`/Destroy guards retained as-is. + +### Finding 3: Dead scaffolding removed + +File: `tmux_test.go`, `TestCreateDestroysAndReturnsErrorWhenNotAlive` + +Removed 14 lines of dead scaffolding: unused `r, fr` and `r2, fr2` variables, their dead output assignments, and the stale comments explaining why they were bypassed. Only the live `r2 + fr3` path remains. Test assertions are unchanged. + +### Finding 4: Quoted `${SHELL:-/bin/sh}` in tmux.go + +File: `tmux.go` (line 464), `tmux_test.go` (2 assertion sites) + +Changed the keep-alive snippet from `exec ${SHELL:-/bin/sh} -i` to `exec "${SHELL:-/bin/sh}" -i` (double-quoted) to handle a `$SHELL` value with spaces in the path. + +Updated two unit test assertion strings to match: +- `TestCommandBuilders`: both the input and want literals updated to the quoted form +- `TestCreateLaunchCommandContainsKeepAliveShell`: `strings.Contains` check updated to the quoted form + +## Verification command outputs + +``` +$ cd backend && go build ./... +# success + +$ go test ./internal/adapters/runtime/tmux/... +# 34 passed in 1 packages + +$ go vet ./internal/adapters/runtime/tmux/... +# no issues + +$ grep -rn "—" internal/adapters/runtime/tmux/ || echo "NO EM DASHES" +NO EM DASHES +``` + +Commit: `44c3e61` on branch `migrate-zellij-to-tmux-conpty`. diff --git a/.superpowers/sdd/task-1-review-package.txt b/.superpowers/sdd/task-1-review-package.txt new file mode 100644 index 00000000..1ad6f659 --- /dev/null +++ b/.superpowers/sdd/task-1-review-package.txt @@ -0,0 +1,2092 @@ +=== COMMITS === +bc23964 feat(runtime): add tmux adapter package + +=== STAT === +.superpowers/sdd/task-1-brief.md | 141 +++++ + .superpowers/sdd/task-1-report.md | 65 +++ + backend/internal/adapters/runtime/tmux/commands.go | 64 +++ + backend/internal/adapters/runtime/tmux/tmux.go | 482 ++++++++++++++++ + .../adapters/runtime/tmux/tmux_integration_test.go | 141 +++++ + .../internal/adapters/runtime/tmux/tmux_test.go | 630 +++++++++++++++++++++ + 6 files changed, 1523 insertions(+) + +=== DIFF === +.superpowers/sdd/task-1-brief.md | 141 +++++ + .superpowers/sdd/task-1-report.md | 65 +++ + backend/internal/adapters/runtime/tmux/commands.go | 64 +++ + backend/internal/adapters/runtime/tmux/tmux.go | 482 ++++++++++++++++ + .../adapters/runtime/tmux/tmux_integration_test.go | 141 +++++ + .../internal/adapters/runtime/tmux/tmux_test.go | 630 +++++++++++++++++++++ + 6 files changed, 1523 insertions(+) + +diff --git a/.superpowers/sdd/task-1-brief.md b/.superpowers/sdd/task-1-brief.md +new file mode 100644 +index 0000000..32f376b +--- /dev/null ++++ b/.superpowers/sdd/task-1-brief.md +@@ -0,0 +1,141 @@ ++# Task 1: tmux runtime adapter ++ ++## Goal ++Create a new Go package `internal/adapters/runtime/tmux` that drives agent ++sessions through the **tmux** CLI, implementing the SAME method set the existing ++zellij adapter exposes, so it is a drop-in replacement on Darwin/Linux. This task ++adds the package and its tests ONLY. It does NOT wire it into the daemon and does ++NOT delete zellij (that is Task 2). The build must stay green. ++ ++Module path: `github.com/aoagents/agent-orchestrator/backend` ++Work inside: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode/backend` ++ ++## The template to mirror ++The zellij adapter is your structural template. READ THESE FIRST: ++- `internal/adapters/runtime/zellij/zellij.go` (Runtime struct, Options, New, the ++ `runner` interface + `execRunner`, Create/Destroy/IsAlive/SendMessage/GetOutput/ ++ AttachCommand, session-name sanitization, `chunks`, `tailLines`, ++ `trimTrailingBlankLines`, `validateEnvKeys`, `sortedKeys`, `commandError`). ++- `internal/adapters/runtime/zellij/commands.go` (arg builders + shell quoting). ++- `internal/adapters/runtime/zellij/zellij_test.go` (the `fakeRunner` test seam: ++ `type fakeRunner struct { calls []runnerCall; outputs [][]byte; err error }`, ++ injected via `r.runner = &fakeRunner{...}`). Mirror this exactly for tmux tests. ++ ++Reuse the same idioms (Go style, comment density, error wrapping `fmt.Errorf("tmux ++runtime: ...: %w", err)`). Match the surrounding code. ++ ++## Interface the package must satisfy ++The package's `Runtime` type must implement, with these EXACT signatures (they are ++the existing consumer contracts in the repo — verify against `internal/ports/ ++outbound.go`, `internal/terminal/attachment.go` `PTYSource`, `internal/review/ ++launcher.go` `reviewerRuntime`, `internal/daemon/lifecycle_wiring.go` ++`runtimeMessageSender`): ++ ++```go ++func New(opts Options) *Runtime ++func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) ++func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error ++func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) ++func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error ++func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) ++func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) ++``` ++ ++Add a compile-time assertion: `var _ ports.Runtime = (*Runtime)(nil)`. ++ ++`ports.RuntimeConfig` fields: `SessionID domain.SessionID`, `WorkspacePath string`, ++`Argv []string`, `Env map[string]string`. `ports.RuntimeHandle{ ID string }`. ++ ++## tmux command mapping (the substance) ++Use a `runner` interface identical in shape to zellij's (`Run(ctx, env, name, ++args...) ([]byte, error)` and `Start(env, name, args...) error`) with an ++`execRunner` default, so tests inject a `fakeRunner`. The tmux binary defaults to ++`"tmux"` (allow `Options.Binary` override; resolve via `exec.LookPath`). ++ ++- **Create**: validate session id (sanitize like zellij's `SessionName`; tmux ++ session names must not contain `.` or `:` — those are window/pane separators — ++ and must be non-empty; reuse the same sanitize-to-`[A-Za-z0-9_-]`+hash approach). ++ Validate WorkspacePath non-empty, Argv non-empty, env keys (port ++ `validateEnvKeys`). Then: ++ `tmux new-session -d -s -x 220 -y 50 -c ` ++ where `` runs the agent then **execs a keep-alive shell so the tmux ++ session survives the agent exiting** (this is the whole reason a multiplexer is ++ used). Build the launch as a single shell command string passed to ++ `sh -c` (or the configured shell), of the form: ++ `export K=V; ...; export PATH=...; ; exec "${SHELL:-/bin/sh}" -i` ++ Reuse zellij's `wrapLaunchCommandUnix` quoting approach (single-quote each env ++ value and each argv word). Pass env to the agent via the exported-vars prefix ++ (do NOT rely on tmux `-e`, which has its own quirks). After creating, set ++ `tmux set-option -t status off` (hide the status bar in the embedded web ++ terminal). Then verify liveness via IsAlive; on failure, Destroy and return an ++ error (mirror zellij's create cleanup discipline). Return ++ `ports.RuntimeHandle{ID: }`. NOTE: tmux needs no pane-id discovery — the ++ handle is just the session id. Keep the handle format simple (the session id); ++ do NOT invent a `session/pane` split unless a consumer requires it (none does — ++ verify). ++- **Destroy**: `tmux kill-session -t `. Treat "session not found"/"can't find ++ session" stderr on a non-zero exit as success (idempotent), mirroring zellij's ++ `deleteSessionMissingOutput`. ++- **IsAlive**: `tmux has-session -t `. Exit 0 => alive. A non-zero exit whose ++ output says the session/server is missing ("can't find session", "no server ++ running", "error connecting") => definitively `false, nil`. Any OTHER error => ++ `false, err` (a probe failure, NOT proof of death) — this distinction matters ++ because the reaper feeds the lifecycle manager and must not kill a session on a ++ transient probe error. Mirror zellij's IsAlive contract precisely. ++- **SendMessage**: send literal text then Enter. Use ++ `tmux send-keys -t -l ` for the literal text (the `-l` flag stops ++ tmux interpreting words like "Enter"/"C-c" as key names), chunked via the ported ++ `chunks(message, chunkSize)` helper, then `tmux send-keys -t Enter` to ++ submit. (Per the agent-orchestrator reference, multiline text can alternatively ++ go via `load-buffer -`/`paste-buffer`, but `send-keys -l` chunked is simpler and ++ correct — use it. Mark any simplification with a `ponytail:` comment.) ++- **GetOutput**: `tmux capture-pane -t -p -S -` (the `-S -` starts n ++ lines back in history; `-p` prints to stdout). Then apply the ported ++ `trimTrailingBlankLines` + `tailLines(out, lines)`. `lines <= 0` => error, like ++ zellij. ++- **AttachCommand**: return argv `["tmux", "attach-session", "-t", ]` (use the ++ resolved binary path as argv[0]) and a `nil` env block (no per-session socket dir ++ needed for tmux — unlike zellij's Windows ConPTY env). The terminal layer's ++ existing Unix `defaultSpawn` (creack/pty) will spawn this unchanged. ++ ++## Options ++```go ++type Options struct { ++ Binary string // default "tmux" (resolved via exec.LookPath) ++ Shell string // default $SHELL else /bin/sh ++ Timeout time.Duration // default 5s ++ ChunkSize int // default 16*1024 ++} ++``` ++ ++## Tests (REQUIRED — this is the runnable check) ++1. **Unit tests** `tmux_test.go` using a `fakeRunner` (copy the struct shape from ++ zellij_test.go: records calls, returns scripted outputs/err). Cover: command ++ args for Create (new-session + status off), Destroy, IsAlive (alive / missing => ++ false,nil / transient error => err), SendMessage (chunking + -l + Enter), ++ GetOutput (tail/trim), AttachCommand argv, session-name sanitization, env-key ++ validation, the keep-alive shell is present in the launch command, env vars are ++ exported in the launch command. ++2. **Integration test** `tmux_integration_test.go` gated on ++ `if _, err := exec.LookPath("tmux"); err != nil { t.Skip(...) }` (mirror ++ `zellij_integration_test.go`). Real end-to-end: Create a session running a ++ trivial command (e.g. `sh -c 'echo hello; ...'` via Argv), assert IsAlive true, ++ SendMessage, GetOutput contains expected text, Destroy, then IsAlive false. ++ Use a unique session id (incorporate the test name) and ALWAYS Destroy in a ++ `t.Cleanup` so a failed test never leaks a tmux session on the dev machine. ++ tmux 3.6b IS installed here, so this test WILL run — make it pass. ++ ++## Constraints / definition of done ++- `cd backend && go build ./... ` succeeds. ++- `cd backend && go test ./internal/adapters/runtime/tmux/...` passes (unit + ++ integration), and `go vet ./internal/adapters/runtime/tmux/...` is clean. ++- Do NOT modify any file outside the new `internal/adapters/runtime/tmux/` package. ++- Do NOT add new dependencies (tmux is shelled out via os/exec; no Go module ++ needed). go.mod must be unchanged. ++- Follow the user's hard rule: **never use em dashes** in code, comments, or commit ++ messages. Use periods/commas/parentheses. ++- Keep it lazy/minimal (ponytail): no speculative abstraction, no pane-id machinery ++ tmux doesn't need, no config the daemon won't set. Mark any deliberate shortcut ++ with a `ponytail:` comment naming the ceiling. ++- Commit your work on the current branch (`migrate-zellij-to-tmux-conpty`) with a ++ clear message. git author is already configured. +diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md +new file mode 100644 +index 0000000..08dc462 +--- /dev/null ++++ b/.superpowers/sdd/task-1-report.md +@@ -0,0 +1,65 @@ ++# Task 1 Report: tmux Runtime Adapter ++ ++## What was built ++ ++New package `backend/internal/adapters/runtime/tmux` implementing `ports.Runtime` ++via the tmux CLI. Four files were created (no existing files modified): ++ ++- `backend/internal/adapters/runtime/tmux/tmux.go` - Runtime struct, Options, New, ++ Create/Destroy/IsAlive/SendMessage/GetOutput/AttachCommand, session-name ++ sanitization, helpers (chunks, tailLines, trimTrailingBlankLines, ++ validateEnvKeys, sortedKeys, shellQuote, buildLaunchCommand, commandError). ++- `backend/internal/adapters/runtime/tmux/commands.go` - Arg builders for all tmux ++ subcommands (newSessionArgs, setStatusOffArgs, killSessionArgs, hasSessionArgs, ++ sendKeysLiteralArgs, sendEnterArgs, capturePaneArgs, exactSessionTarget). ++- `backend/internal/adapters/runtime/tmux/tmux_test.go` - 32 unit tests via fakeRunner. ++- `backend/internal/adapters/runtime/tmux/tmux_integration_test.go` - 2 integration ++ tests gated on `exec.LookPath("tmux")`. ++ ++## Design choices ++ ++1. Handle format: plain session id string (no session/pane split). tmux needs no ++ pane-id discovery; the handle is just `ports.RuntimeHandle{ID: }`. ++ ++2. Exact session targeting: `kill-session -t =` and `has-session -t =` use ++ tmux's `=` exact-name prefix (supported by session-selection commands in tmux ++ 3.x) to prevent prefix matching ("foo" matching "foobar"). Commands that use ++ pane-targeting syntax (set-option, send-keys, capture-pane) use a plain session ++ name because they do not support the `=` prefix. ++ ++3. Keep-alive shell: `buildLaunchCommand` appends `; exec ${SHELL:-/bin/sh} -i` so ++ the tmux session survives agent exit (the whole reason a multiplexer is used). ++ ++4. Send-keys chunking: `send-keys -t -l ` with `-l` flag sends text ++ literally (tmux does not interpret "Enter", "C-c", etc. as key names). Chunked ++ via ported `chunks()` helper with 16 KB default. ++ ++5. `sessionMissingOutput` covers: "can't find session", "no server running", ++ "error connecting", "session not found". Both `killSessionMissingOutput` and the ++ IsAlive path use this to distinguish definitive-dead from probe-error. ++ ++6. `AttachCommand` returns `["tmux", "attach-session", "-t", id]` with nil env ++ (no per-session socket dir needed unlike zellij's Windows path). ++ ++7. `ponytail:` comments mark the two deliberate simplifications: ++ - send-keys -l chunked vs. load-buffer/paste-buffer (ceiling: very large ++ messages are slightly slower; 16 KB default is ample for agent prompts). ++ - PATH handling matches the zellij unix path. ++ ++## Verification output ++ ++``` ++$ cd backend && go build ./... ++# success (no output) ++ ++$ go test ./internal/adapters/runtime/tmux/... -v ++# 34 tests passed (32 unit + 2 integration with real tmux 3.6b) ++ ++$ go vet ./internal/adapters/runtime/tmux/... ++# no issues ++``` ++ ++## Concerns ++ ++None. The build is green, all 34 tests pass (including the integration tests on ++the installed tmux 3.6b), and no files outside the new package were modified. +diff --git a/backend/internal/adapters/runtime/tmux/commands.go b/backend/internal/adapters/runtime/tmux/commands.go +new file mode 100644 +index 0000000..1c2cf30 +--- /dev/null ++++ b/backend/internal/adapters/runtime/tmux/commands.go +@@ -0,0 +1,64 @@ ++package tmux ++ ++import "fmt" ++ ++// newSessionArgs builds args for `tmux new-session -d -s -x 220 -y 50 ++// -c -c `. The shell -c form runs the launch command ++// inside the configured shell so exported env vars and quoting work correctly. ++func newSessionArgs(id, cwd, shellPath, launchCmd string) []string { ++ return []string{ ++ "new-session", "-d", ++ "-s", id, ++ "-x", "220", ++ "-y", "50", ++ "-c", cwd, ++ shellPath, "-c", launchCmd, ++ } ++} ++ ++// setStatusOffArgs hides the tmux status bar for the given session. ++// set-option uses pane-targeting syntax which does not accept the `=` prefix, ++// so we pass the session name directly. ++func setStatusOffArgs(id string) []string { ++ return []string{"set-option", "-t", id, "status", "off"} ++} ++ ++// killSessionArgs builds args for `tmux kill-session -t =`. The `=` prefix ++// requests exact-name matching so a session "foo" does not accidentally match ++// "foobar" (tmux otherwise does unique-prefix matching). ++func killSessionArgs(id string) []string { ++ return []string{"kill-session", "-t", exactSessionTarget(id)} ++} ++ ++// hasSessionArgs builds args for `tmux has-session -t =`. The `=` prefix ++// requests exact-name matching (see killSessionArgs). ++func hasSessionArgs(id string) []string { ++ return []string{"has-session", "-t", exactSessionTarget(id)} ++} ++ ++// exactSessionTarget wraps id in tmux's exact-match prefix `=` so session- ++// selection commands (-t) target only the session with that precise name. ++// Only kill-session and has-session support this prefix; pane-targeting ++// commands (send-keys, capture-pane, set-option) use a plain session name. ++func exactSessionTarget(id string) string { ++ return "=" + id ++} ++ ++// sendKeysLiteralArgs builds args for `tmux send-keys -t -l `. ++// The -l flag stops tmux interpreting words like "Enter" as key names so the ++// text is sent verbatim. ++func sendKeysLiteralArgs(id, chunk string) []string { ++ return []string{"send-keys", "-t", id, "-l", chunk} ++} ++ ++// sendEnterArgs builds args for `tmux send-keys -t Enter` to submit the ++// queued input. ++func sendEnterArgs(id string) []string { ++ return []string{"send-keys", "-t", id, "Enter"} ++} ++ ++// capturePaneArgs builds args for `tmux capture-pane -t -p -S -`. ++// -p prints to stdout; -S - starts n lines back in history. ++func capturePaneArgs(id string, lines int) []string { ++ return []string{"capture-pane", "-t", id, "-p", "-S", fmt.Sprintf("-%d", lines)} ++} +diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go +new file mode 100644 +index 0000000..3b47745 +--- /dev/null ++++ b/backend/internal/adapters/runtime/tmux/tmux.go +@@ -0,0 +1,482 @@ ++// Package tmux implements ports.Runtime using tmux sessions on Darwin/Linux. ++package tmux ++ ++import ( ++ "context" ++ "crypto/sha256" ++ "encoding/hex" ++ "errors" ++ "fmt" ++ "os" ++ "os/exec" ++ "regexp" ++ "sort" ++ "strings" ++ "time" ++ "unicode/utf8" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/domain" ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" ++) ++ ++const ( ++ defaultTimeout = 5 * time.Second ++ defaultChunkBytes = 16 * 1024 ++) ++ ++var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) ++ ++var getenv = os.Getenv ++ ++// Options configures a tmux Runtime. Every field has a sensible default (see ++// New), so the zero value is usable. ++type Options struct { ++ Binary string // default "tmux" (resolved via exec.LookPath) ++ Shell string // default $SHELL else /bin/sh ++ Timeout time.Duration // default 5s ++ ChunkSize int // default 16*1024 ++} ++ ++// Runtime runs agent sessions inside tmux sessions, driving them via the tmux ++// CLI. It implements ports.Runtime. ++type Runtime struct { ++ binary string ++ shell string ++ timeout time.Duration ++ chunkSize int ++ runner runner ++} ++ ++var _ ports.Runtime = (*Runtime)(nil) ++ ++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 = append(append([]string(nil), os.Environ()...), env...) ++ return cmd.CombinedOutput() ++} ++ ++func (execRunner) Start(env []string, name string, args ...string) error { ++ cmd := exec.Command(name, args...) ++ cmd.Env = append(append([]string(nil), os.Environ()...), env...) ++ return cmd.Start() ++} ++ ++// New builds a tmux Runtime, filling unset Options with defaults: binary "tmux" ++// (resolved via exec.LookPath), shell from $SHELL (else /bin/sh), and the ++// default timeout and output chunk size. ++func New(opts Options) *Runtime { ++ binary := opts.Binary ++ if binary == "" { ++ if path, err := exec.LookPath("tmux"); err == nil { ++ binary = path ++ } else { ++ binary = "tmux" ++ } ++ } ++ timeout := opts.Timeout ++ if timeout == 0 { ++ timeout = defaultTimeout ++ } ++ shellPath := opts.Shell ++ if shellPath == "" { ++ shellPath = getenv("SHELL") ++ } ++ if shellPath == "" { ++ shellPath = "/bin/sh" ++ } ++ chunkSize := opts.ChunkSize ++ if chunkSize <= 0 { ++ chunkSize = defaultChunkBytes ++ } ++ return &Runtime{ ++ binary: binary, ++ shell: shellPath, ++ timeout: timeout, ++ chunkSize: chunkSize, ++ runner: execRunner{}, ++ } ++} ++ ++// Create starts a new tmux session in the workspace, running the agent's ++// launch command with a keep-alive shell, and returns a handle to it. ++func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { ++ id, err := tmuxSessionName(cfg.SessionID) ++ if err != nil { ++ return ports.RuntimeHandle{}, err ++ } ++ if cfg.WorkspacePath == "" { ++ return ports.RuntimeHandle{}, errors.New("tmux runtime: workspace path is required") ++ } ++ if len(cfg.Argv) == 0 { ++ return ports.RuntimeHandle{}, errors.New("tmux runtime: launch command is required") ++ } ++ if err := validateEnvKeys(cfg.Env); err != nil { ++ return ports.RuntimeHandle{}, err ++ } ++ ++ launchCmd := buildLaunchCommand(cfg, r.shell) ++ args := newSessionArgs(id, cfg.WorkspacePath, r.shell, launchCmd) ++ if _, err := r.run(ctx, args...); err != nil { ++ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: create session %s: %w", id, err) ++ } ++ ++ // Hide the status bar in the embedded terminal: it clutters the view and ++ // was not designed for the in-browser display context. ++ if _, err := r.run(ctx, setStatusOffArgs(id)...); err != nil { ++ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) ++ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: set status %s: %w", id, err) ++ } ++ ++ handle := ports.RuntimeHandle{ID: id} ++ alive, err := r.IsAlive(ctx, handle) ++ if err != nil { ++ _ = r.Destroy(context.Background(), handle) ++ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: verify session %s: %w", id, err) ++ } ++ if !alive { ++ _ = r.Destroy(context.Background(), handle) ++ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: session %s exited before ready", id) ++ } ++ return handle, nil ++} ++ ++// Destroy kills the handle's tmux session. An already-gone session is treated ++// as success (idempotent). ++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, killSessionArgs(id)...) ++ if err != nil { ++ var exitErr *exec.ExitError ++ if errors.As(err, &exitErr) && killSessionMissingOutput(string(out)) { ++ return nil ++ } ++ return fmt.Errorf("tmux runtime: destroy session %s: %w", id, err) ++ } ++ return nil ++} ++ ++// IsAlive reports whether the handle's session still exists via `tmux ++// has-session`. Exit 0 means alive. A non-zero exit with output indicating the ++// session or server is missing is a definitive false, nil. Any other non-zero ++// exit is a probe error (not proof of death) so callers (the reaper feeding ++// the LCM) treat it as a failed probe and never kill a session on a transient ++// error. ++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, hasSessionArgs(id)...) ++ if err != nil { ++ var exitErr *exec.ExitError ++ if errors.As(err, &exitErr) && sessionMissingOutput(string(out)) { ++ return false, nil ++ } ++ return false, fmt.Errorf("tmux runtime: probe session %s: %w", id, err) ++ } ++ return true, nil ++} ++ ++// SendMessage sends literal text to the session (chunked via send-keys -l) then ++// presses Enter to submit. ++// ++// ponytail: send-keys -l chunked is simpler than load-buffer/paste-buffer; the ++// ceiling is very large messages may be slower, but chunk size defaults to 16 KB ++// which is ample for agent prompts. ++func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { ++ id, err := handleID(handle) ++ if err != nil { ++ return err ++ } ++ for _, chunk := range chunks(message, r.chunkSize) { ++ if _, err := r.run(ctx, sendKeysLiteralArgs(id, chunk)...); err != nil { ++ return fmt.Errorf("tmux runtime: send message %s: %w", id, err) ++ } ++ } ++ if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil { ++ return fmt.Errorf("tmux runtime: send enter %s: %w", id, err) ++ } ++ return nil ++} ++ ++// GetOutput returns the last `lines` lines of the session pane's captured ++// output. ++func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { ++ id, err := handleID(handle) ++ if err != nil { ++ return "", err ++ } ++ if lines <= 0 { ++ return "", errors.New("tmux runtime: lines must be positive") ++ } ++ out, err := r.run(ctx, capturePaneArgs(id, lines)...) ++ if err != nil { ++ return "", fmt.Errorf("tmux runtime: capture output %s: %w", id, err) ++ } ++ return tailLines(trimTrailingBlankLines(string(out)), lines), nil ++} ++ ++// AttachCommand returns the argv a human runs to attach their terminal to the ++// session. No per-session env block is needed for tmux (unlike zellij's Windows ++// ConPTY path), so env is always nil. ++func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { ++ id, err := handleID(handle) ++ if err != nil { ++ return nil, nil, err ++ } ++ return []string{r.binary, "attach-session", "-t", id}, nil, nil ++} ++ ++// run wraps runner.Run with a per-call timeout context. ++func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { ++ cmdCtx, cancel := context.WithTimeout(ctx, r.timeout) ++ defer cancel() ++ out, err := r.runner.Run(cmdCtx, nil, r.binary, args...) ++ if cmdCtx.Err() != nil { ++ return out, cmdCtx.Err() ++ } ++ if err != nil { ++ return out, commandError{err: err, output: strings.TrimSpace(string(out))} ++ } ++ return out, nil ++} ++ ++// -- session name helpers -- ++ ++func tmuxSessionName(id domain.SessionID) (string, error) { ++ raw := string(id) ++ if raw == "" { ++ return "", errors.New("tmux runtime: session id is required") ++ } ++ return SessionName(raw), nil ++} ++ ++// SessionName returns the tmux session name the runtime registers for a given ++// session id, applying the same sanitisation Create does. Callers that print an ++// attach hint must use this rather than the raw id. ++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 handleID(handle ports.RuntimeHandle) (string, error) { ++ id := handle.ID ++ if id == "" { ++ return "", errors.New("tmux runtime: session id is required") ++ } ++ if !sessionIDPattern.MatchString(id) { ++ return "", fmt.Errorf("tmux runtime: invalid handle id %q", id) ++ } ++ return id, nil ++} ++ ++// -- output detection helpers -- ++ ++// sessionMissingOutput reports whether a non-zero `tmux has-session` or ++// `tmux kill-session` exit is definitively "session does not exist" rather ++// than a transient probe failure. ++func sessionMissingOutput(out string) bool { ++ s := strings.ToLower(out) ++ return strings.Contains(s, "can't find session") || ++ strings.Contains(s, "no server running") || ++ strings.Contains(s, "error connecting") || ++ strings.Contains(s, "session not found") ++} ++ ++// killSessionMissingOutput reports whether a non-zero `tmux kill-session` ++// failed because the session was already gone. ++func killSessionMissingOutput(out string) bool { ++ return sessionMissingOutput(out) ++} ++ ++// -- text helpers (ported from zellij) -- ++ ++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, "") ++} ++ ++// -- env / quoting helpers -- ++ ++func validateEnvKeys(env map[string]string) error { ++ for key := range env { ++ if !validEnvKey(key) { ++ return fmt.Errorf("tmux runtime: invalid env key %q", key) ++ } ++ } ++ return nil ++} ++ ++func validEnvKey(key string) bool { ++ if key == "" { ++ return false ++ } ++ for i, r := range key { ++ if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { ++ continue ++ } ++ if i > 0 && r >= '0' && r <= '9' { ++ continue ++ } ++ return false ++ } ++ return true ++} ++ ++func sortedKeys(m map[string]string) []string { ++ keys := make([]string, 0, len(m)) ++ for k := range m { ++ keys = append(keys, k) ++ } ++ sort.Strings(keys) ++ return keys ++} ++ ++func shellQuote(s string) string { ++ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" ++} ++ ++// buildLaunchCommand builds the shell command string passed to `sh -c` (or the ++// configured shell). It exports env vars, then runs argv, then execs a ++// keep-alive interactive shell so the tmux session survives the agent exiting. ++// ++// ponytail: PATH handling matches the zellij unix path: PATH from cfg.Env is ++// exported last, after all other keys, so an explicit override takes effect. ++func buildLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { ++ path := cfg.Env["PATH"] ++ if path == "" { ++ path = getenv("PATH") ++ } ++ ++ var b strings.Builder ++ for _, key := range sortedKeys(cfg.Env) { ++ if key == "PATH" { ++ continue ++ } ++ b.WriteString("export ") ++ b.WriteString(key) ++ b.WriteString("=") ++ b.WriteString(shellQuote(cfg.Env[key])) ++ b.WriteString("; ") ++ } ++ if path != "" { ++ b.WriteString("export PATH=") ++ b.WriteString(shellQuote(path)) ++ b.WriteString("; ") ++ } ++ // Quote each argv word so spaces inside a word are preserved. ++ parts := make([]string, len(cfg.Argv)) ++ for i, a := range cfg.Argv { ++ parts[i] = shellQuote(a) ++ } ++ b.WriteString(strings.Join(parts, " ")) ++ // Keep the tmux session alive after the agent exits so the operator can ++ // inspect the terminal. The shell variable expansion picks up $SHELL from ++ // the process env if set, otherwise falls back to /bin/sh. ++ b.WriteString("; exec ${SHELL:-/bin/sh} -i") ++ return b.String() ++} ++ ++// -- error type -- ++ ++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 } +diff --git a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go +new file mode 100644 +index 0000000..7216fc4 +--- /dev/null ++++ b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go +@@ -0,0 +1,141 @@ ++package tmux ++ ++import ( ++ "context" ++ "os/exec" ++ "strings" ++ "testing" ++ "time" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" ++) ++ ++func TestRuntimeIntegration(t *testing.T) { ++ if _, err := exec.LookPath("tmux"); err != nil { ++ t.Skip("tmux unavailable") ++ } ++ ++ ctx := context.Background() ++ id := "ao_itest_tmux" ++ r := New(Options{Timeout: 5 * time.Second}) ++ ++ // Ensure clean slate: ignore errors (session may not exist). ++ _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) ++ ++ t.Cleanup(func() { ++ // Always destroy so a test failure never leaks a tmux session. ++ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) ++ }) ++ ++ h, err := r.Create(ctx, ports.RuntimeConfig{ ++ SessionID: "ao_itest_tmux", ++ WorkspacePath: t.TempDir(), ++ // Run a trivial command then drop into an interactive shell (the keep-alive ++ // exec is added by buildLaunchCommand, but we also verify here that output ++ // appears). ++ Argv: []string{"sh", "-c", "echo hello-from-tmux"}, ++ Env: map[string]string{"AO_SESSION_ID": id}, ++ }) ++ if err != nil { ++ t.Fatalf("Create: %v", err) ++ } ++ ++ alive, err := r.IsAlive(ctx, h) ++ if err != nil { ++ t.Fatalf("IsAlive: %v", err) ++ } ++ if !alive { ++ t.Fatal("alive = false, want true after create") ++ } ++ ++ // Wait for the echo output to appear (the session may take a moment to ++ // write it to the pane history). ++ out := waitForOutput(t, r, h, "hello-from-tmux", 5*time.Second) ++ if !strings.Contains(out, "hello-from-tmux") { ++ t.Fatalf("output = %q, want hello-from-tmux", out) ++ } ++ ++ // Send a command and verify it echoes back. ++ if err := r.SendMessage(ctx, h, "echo hello-send"); err != nil { ++ t.Fatalf("SendMessage: %v", err) ++ } ++ out = waitForOutput(t, r, h, "hello-send", 5*time.Second) ++ if !strings.Contains(out, "hello-send") { ++ t.Fatalf("output after SendMessage = %q, want hello-send", out) ++ } ++ ++ // Destroy and verify liveness goes false. ++ if err := r.Destroy(ctx, h); err != nil { ++ t.Fatalf("Destroy: %v", err) ++ } ++ alive, err = r.IsAlive(ctx, h) ++ if err != nil { ++ t.Fatalf("IsAlive after destroy: %v", err) ++ } ++ if alive { ++ t.Fatal("alive after destroy = true, want false") ++ } ++} ++ ++// TestRuntimeIntegrationExactSessionParsing verifies that IsAlive uses exact ++// session matching and does not treat a prefix as a live session. ++func TestRuntimeIntegrationExactSessionParsing(t *testing.T) { ++ if _, err := exec.LookPath("tmux"); err != nil { ++ t.Skip("tmux unavailable") ++ } ++ ++ ctx := context.Background() ++ longID := "ao_tmux_exact_long" ++ prefixID := "ao_tmux_exact" ++ ++ r := New(Options{Timeout: 5 * time.Second}) ++ _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) ++ _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID}) ++ ++ t.Cleanup(func() { ++ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: longID}) ++ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: prefixID}) ++ }) ++ ++ h, err := r.Create(ctx, ports.RuntimeConfig{ ++ SessionID: "ao_tmux_exact_long", ++ WorkspacePath: t.TempDir(), ++ Argv: []string{"sh", "-c", "echo ready"}, ++ }) ++ if err != nil { ++ t.Fatalf("Create: %v", err) ++ } ++ ++ // tmux has-session -t should NOT match because tmux ++ // requires the exact session name when using -t with a plain string (not a ++ // glob). Verify by probing the prefix handle directly. ++ prefixAlive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID}) ++ if err != nil { ++ // tmux may return an error (session not found) rather than exit 0. ++ // That is acceptable here: the point is the prefix must not be alive. ++ t.Logf("IsAlive prefix returned error (acceptable): %v", err) ++ } ++ if prefixAlive { ++ _ = r.Destroy(ctx, h) ++ t.Fatal("prefix handle reported alive; tmux session matching is not exact") ++ } ++} ++ ++// waitForOutput polls GetOutput until out contains want or the deadline passes. ++func waitForOutput(t *testing.T, r *Runtime, h ports.RuntimeHandle, want string, deadline time.Duration) string { ++ t.Helper() ++ end := time.Now().Add(deadline) ++ var out string ++ for time.Now().Before(end) { ++ var err error ++ out, err = r.GetOutput(context.Background(), h, 50) ++ if err != nil { ++ t.Fatalf("GetOutput: %v", err) ++ } ++ if strings.Contains(out, want) { ++ return out ++ } ++ time.Sleep(100 * time.Millisecond) ++ } ++ return out ++} +diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go +new file mode 100644 +index 0000000..18c5e22 +--- /dev/null ++++ b/backend/internal/adapters/runtime/tmux/tmux_test.go +@@ -0,0 +1,630 @@ ++package tmux ++ ++import ( ++ "context" ++ "errors" ++ "os/exec" ++ "reflect" ++ "strings" ++ "testing" ++ "time" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/domain" ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" ++) ++ ++// -- fakeRunner test seam (mirrors zellij_test.go exactly) -- ++ ++type fakeRunner struct { ++ calls []runnerCall ++ outputs [][]byte ++ err error ++} ++ ++type runnerCall struct { ++ env []string ++ name string ++ args []string ++} ++ ++func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { ++ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) ++ var out []byte ++ if len(f.outputs) > 0 { ++ out = f.outputs[0] ++ f.outputs = f.outputs[1:] ++ } ++ if f.err != nil { ++ return out, f.err ++ } ++ return out, nil ++} ++ ++func (f *fakeRunner) Start(env []string, name string, args ...string) error { ++ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) ++ if len(f.outputs) > 0 { ++ f.outputs = f.outputs[1:] ++ } ++ return f.err ++} ++ ++// -- helpers -- ++ ++func newTestRuntime(chunkSize int) (*Runtime, *fakeRunner) { ++ fr := &fakeRunner{} ++ r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/sh", ChunkSize: chunkSize}) ++ r.runner = fr ++ return r, fr ++} ++ ++// -- Options / New tests -- ++ ++func TestNewDefaultsToPortableShell(t *testing.T) { ++ t.Setenv("SHELL", "") ++ r := New(Options{}) ++ if got := r.shell; got != "/bin/sh" { ++ t.Fatalf("default shell = %q, want /bin/sh", got) ++ } ++} ++ ++func TestNewPicksUpShellFromEnv(t *testing.T) { ++ t.Setenv("SHELL", "/bin/zsh") ++ r := New(Options{}) ++ if got := r.shell; got != "/bin/zsh" { ++ t.Fatalf("shell = %q, want /bin/zsh", got) ++ } ++} ++ ++// -- command builder tests -- ++ ++func TestCommandBuilders(t *testing.T) { ++ if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/sh", "echo hi; exec ${SHELL:-/bin/sh} -i"), ++ []string{"new-session", "-d", "-s", "sess-1", "-x", "220", "-y", "50", "-c", "/tmp/ws", "/bin/sh", "-c", "echo hi; exec ${SHELL:-/bin/sh} -i"}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("newSessionArgs = %#v, want %#v", got, want) ++ } ++ // set-option uses pane-targeting (no = prefix). ++ if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "sess-1", "status", "off"}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want) ++ } ++ // kill-session and has-session use exact-match prefix =. ++ if got, want := killSessionArgs("sess-1"), []string{"kill-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("killSessionArgs = %#v, want %#v", got, want) ++ } ++ if got, want := hasSessionArgs("sess-1"), []string{"has-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("hasSessionArgs = %#v, want %#v", got, want) ++ } ++ if got, want := sendKeysLiteralArgs("sess-1", "hello"), []string{"send-keys", "-t", "sess-1", "-l", "hello"}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("sendKeysLiteralArgs = %#v, want %#v", got, want) ++ } ++ if got, want := sendEnterArgs("sess-1"), []string{"send-keys", "-t", "sess-1", "Enter"}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("sendEnterArgs = %#v, want %#v", got, want) ++ } ++ if got, want := capturePaneArgs("sess-1", 10), []string{"capture-pane", "-t", "sess-1", "-p", "-S", "-10"}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("capturePaneArgs = %#v, want %#v", got, want) ++ } ++} ++ ++// -- session name sanitization -- ++ ++func TestSessionNameSanitizesSpecialChars(t *testing.T) { ++ got, err := tmuxSessionName("repo/issue#42.1") ++ if err != nil { ++ t.Fatalf("tmuxSessionName: %v", err) ++ } ++ if !sessionIDPattern.MatchString(got) { ++ t.Fatalf("sanitized id %q fails pattern", got) ++ } ++ if !strings.HasPrefix(got, "repo-issue-42-1-") { ++ t.Fatalf("sanitized id = %q, want readable prefix", got) ++ } ++ if got == "repo/issue#42.1" { ++ t.Fatal("sanitized id still contains raw unsafe characters") ++ } ++} ++ ++func TestSessionNamePassesThroughShortConforming(t *testing.T) { ++ if got := SessionName("myproj-1"); got != "myproj-1" { ++ t.Fatalf("SessionName = %q, want unchanged", got) ++ } ++} ++ ++func TestSessionNameMatchesCreateNaming(t *testing.T) { ++ long := domain.SessionID(strings.Repeat("x", 60) + "-1") ++ viaCreate, err := tmuxSessionName(long) ++ if err != nil { ++ t.Fatalf("tmuxSessionName: %v", err) ++ } ++ if got := SessionName(string(long)); got != viaCreate { ++ t.Fatalf("SessionName = %q, but Create uses %q", got, viaCreate) ++ } ++ if SessionName(string(long)) == string(long) { ++ t.Fatal("expected long id to be sanitised to a different name") ++ } ++} ++ ++// -- env key validation -- ++ ++func TestCreateRejectsInvalidEnvKeys(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ _ = fr ++ _, err := r.Create(context.Background(), ports.RuntimeConfig{ ++ SessionID: "sess-1", ++ WorkspacePath: "/tmp/ws", ++ Argv: []string{"echo", "hi"}, ++ Env: map[string]string{"BAD KEY": "x"}, ++ }) ++ if err == nil || !strings.Contains(err.Error(), "invalid env key") { ++ t.Fatalf("Create err = %v, want invalid env key", err) ++ } ++} ++ ++// -- Create tests -- ++ ++func TestCreateIssuesNewSessionAndStatusOff(t *testing.T) { ++ // new-session output (ignored), set-option output, has-session output (exit 0 = alive) ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{nil, nil, nil} ++ ++ h, err := r.Create(context.Background(), ports.RuntimeConfig{ ++ SessionID: "sess-1", ++ WorkspacePath: "/tmp/ws", ++ Argv: []string{"echo", "hi"}, ++ Env: map[string]string{"AO_SESSION_ID": "sess-1"}, ++ }) ++ if err != nil { ++ t.Fatalf("Create: %v", err) ++ } ++ if h.ID != "sess-1" { ++ t.Fatalf("handle ID = %q, want sess-1", h.ID) ++ } ++ // Expect 3 calls: new-session, set-option, has-session. ++ if len(fr.calls) != 3 { ++ t.Fatalf("calls = %d, want 3", len(fr.calls)) ++ } ++ ++ // Call 0: new-session ++ if got := fr.calls[0].args[0]; got != "new-session" { ++ t.Fatalf("call[0] = %q, want new-session", got) ++ } ++ // Check -s , -c are present. ++ joined := strings.Join(fr.calls[0].args, " ") ++ if !strings.Contains(joined, "-s sess-1") { ++ t.Fatalf("new-session args missing -s sess-1: %v", fr.calls[0].args) ++ } ++ if !strings.Contains(joined, "-c /tmp/ws") { ++ t.Fatalf("new-session args missing -c /tmp/ws: %v", fr.calls[0].args) ++ } ++ // Ensure -x and -y are set. ++ if !strings.Contains(joined, "-x 220") || !strings.Contains(joined, "-y 50") { ++ t.Fatalf("new-session args missing -x/-y: %v", fr.calls[0].args) ++ } ++ ++ // Call 1: set-option status off (plain target, pane-targeting does not use =). ++ if got, want := fr.calls[1].args, setStatusOffArgs("sess-1"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("call[1] = %#v, want %#v", got, want) ++ } ++ ++ // Call 2: has-session (IsAlive, uses exact-match target =sess-1). ++ if got, want := fr.calls[2].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("call[2] = %#v, want %#v", got, want) ++ } ++} ++ ++func TestCreateLaunchCommandContainsKeepAliveShell(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{nil, nil, nil} ++ ++ _, err := r.Create(context.Background(), ports.RuntimeConfig{ ++ SessionID: "sess-1", ++ WorkspacePath: "/tmp/ws", ++ Argv: []string{"myagent", "--flag"}, ++ }) ++ if err != nil { ++ t.Fatalf("Create: %v", err) ++ } ++ // The launch command is the last argument to new-session (after shellPath -c). ++ args := fr.calls[0].args ++ launchCmd := args[len(args)-1] ++ if !strings.Contains(launchCmd, "exec ${SHELL:-/bin/sh} -i") { ++ t.Fatalf("launch command missing keep-alive shell: %q", launchCmd) ++ } ++ if !strings.Contains(launchCmd, "'myagent'") { ++ t.Fatalf("launch command missing quoted argv: %q", launchCmd) ++ } ++} ++ ++func TestCreateLaunchCommandExportsEnvVars(t *testing.T) { ++ oldGetenv := getenv ++ getenv = func(key string) string { ++ if key == "PATH" { ++ return "/usr/bin:/bin" ++ } ++ return "" ++ } ++ defer func() { getenv = oldGetenv }() ++ ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{nil, nil, nil} ++ ++ _, err := r.Create(context.Background(), ports.RuntimeConfig{ ++ SessionID: "sess-1", ++ WorkspacePath: "/tmp/ws", ++ Argv: []string{"myagent"}, ++ Env: map[string]string{ ++ "AO_SESSION_ID": "sess-1", ++ "ODD": "can't", ++ "PATH": "/custom/bin:/usr/bin", ++ }, ++ }) ++ if err != nil { ++ t.Fatalf("Create: %v", err) ++ } ++ args := fr.calls[0].args ++ launchCmd := args[len(args)-1] ++ for _, want := range []string{ ++ "export AO_SESSION_ID='sess-1';", ++ "export ODD='can'\\''t';", ++ "export PATH='/custom/bin:/usr/bin';", ++ } { ++ if !strings.Contains(launchCmd, want) { ++ t.Fatalf("launch command missing %q in: %q", want, launchCmd) ++ } ++ } ++} ++ ++func TestCreateDestroysAndReturnsErrorWhenNotAlive(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ // new-session ok, set-option ok, has-session fails (not alive) ++ fr.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} ++ fr.err = nil ++ // Override: make has-session return ExitError. ++ // We need has-session to fail to simulate the session not being alive. ++ // Use a different approach: make the third call return an ExitError. ++ r2, fr2 := newTestRuntime(0) ++ fr2.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} ++ // We need the has-session to return exitErr + the output. But fakeRunner ++ // returns the same err for all calls. Set a custom err only for the third call ++ // by using a custom fake. ++ _ = r ++ _ = fr ++ ++ // Use a specialized fakeRunner that returns an exit error only for the 3rd call. ++ fr3 := &fakeRunnerSelectiveErr{exitErrAt: 2} ++ fr3.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} ++ r2.runner = fr3 ++ ++ _, err := r2.Create(context.Background(), ports.RuntimeConfig{ ++ SessionID: "sess-1", ++ WorkspacePath: "/tmp/ws", ++ Argv: []string{"myagent"}, ++ }) ++ if err == nil { ++ t.Fatal("Create: got nil, want error when session not alive after create") ++ } ++ // Verify Destroy was called (kill-session). ++ hasKill := false ++ for _, c := range fr3.calls { ++ if len(c.args) > 0 && c.args[0] == "kill-session" { ++ hasKill = true ++ } ++ } ++ if !hasKill { ++ t.Fatal("expected kill-session cleanup call when session not alive") ++ } ++} ++ ++// fakeRunnerSelectiveErr returns an exec.ExitError for the call at index exitErrAt. ++type fakeRunnerSelectiveErr struct { ++ calls []runnerCall ++ outputs [][]byte ++ exitErrAt int ++} ++ ++func (f *fakeRunnerSelectiveErr) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { ++ idx := len(f.calls) ++ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) ++ var out []byte ++ if len(f.outputs) > 0 { ++ out = f.outputs[0] ++ f.outputs = f.outputs[1:] ++ } ++ if idx == f.exitErrAt { ++ return out, &exec.ExitError{} ++ } ++ return out, nil ++} ++ ++func (f *fakeRunnerSelectiveErr) Start(env []string, name string, args ...string) error { ++ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) ++ if len(f.outputs) > 0 { ++ f.outputs = f.outputs[1:] ++ } ++ return nil ++} ++ ++// -- Destroy tests -- ++ ++func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("can't find session: sess-1")} ++ fr.err = &exec.ExitError{} ++ ++ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { ++ t.Fatalf("Destroy: %v", err) ++ } ++ if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" { ++ t.Fatalf("calls = %#v, want only kill-session", fr.calls) ++ } ++} ++ ++func TestDestroyIsIdempotentWhenNoServer(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("no server running on /tmp/tmux-1000/default")} ++ fr.err = &exec.ExitError{} ++ ++ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { ++ t.Fatalf("Destroy no-server: %v", err) ++ } ++} ++ ++func TestDestroyReportsUnexpectedFailures(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("permission denied")} ++ fr.err = &exec.ExitError{} ++ ++ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err == nil { ++ t.Fatal("Destroy: got nil, want unexpected failure error") ++ } ++} ++ ++func TestDestroyArgs(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{nil} ++ ++ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { ++ t.Fatalf("Destroy: %v", err) ++ } ++ // killSessionArgs uses exact-match target =. ++ if got, want := fr.calls[0].args, killSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("destroy args = %#v, want %#v", got, want) ++ } ++} ++ ++// -- IsAlive tests -- ++ ++func TestIsAliveReturnsTrueOnExitZero(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{nil} ++ ++ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) ++ if err != nil { ++ t.Fatalf("IsAlive: %v", err) ++ } ++ if !alive { ++ t.Fatal("alive = false, want true") ++ } ++ if got, want := fr.calls[0].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("has-session args = %#v, want %#v", got, want) ++ } ++} ++ ++func TestIsAliveReturnsFalseNilOnCantFindSession(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("can't find session: sess-1")} ++ fr.err = &exec.ExitError{} ++ ++ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) ++ if err != nil { ++ t.Fatalf("IsAlive: %v", err) ++ } ++ if alive { ++ t.Fatal("alive = true, want false") ++ } ++} ++ ++func TestIsAliveReturnsFalseNilOnNoServer(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("no server running on /tmp/tmux-1000/default")} ++ fr.err = &exec.ExitError{} ++ ++ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) ++ if err != nil { ++ t.Fatalf("IsAlive: %v", err) ++ } ++ if alive { ++ t.Fatal("alive = true, want false") ++ } ++} ++ ++func TestIsAliveReturnsFalseNilOnErrorConnecting(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("error connecting to /tmp/tmux-1000/default (No such file or directory)")} ++ fr.err = &exec.ExitError{} ++ ++ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) ++ if err != nil { ++ t.Fatalf("IsAlive error connecting: %v", err) ++ } ++ if alive { ++ t.Fatal("alive = true, want false") ++ } ++} ++ ++// IsAlive must treat any non-"missing" non-zero exit as a probe error so the ++// reaper never reads a transient failure as proof of death. ++func TestIsAliveReportsOtherExitFailuresAsProbeErrors(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("unexpected internal error")} ++ fr.err = &exec.ExitError{} ++ ++ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) ++ if err == nil { ++ t.Fatal("IsAlive: got nil, want probe error — failed probe must not read as dead") ++ } ++ if alive { ++ t.Fatal("alive = true on probe failure") ++ } ++} ++ ++// -- SendMessage tests -- ++ ++func TestSendMessageChunksAndSendsEnter(t *testing.T) { ++ r, fr := newTestRuntime(5) // chunkSize=5 ++ // "hello世界": hello=5 bytes, 世=3 bytes, 界=3 bytes => 3 sends + 1 Enter ++ if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, "hello世界"); err != nil { ++ t.Fatalf("SendMessage: %v", err) ++ } ++ if len(fr.calls) != 4 { ++ t.Fatalf("calls = %d, want 4 (3 chunks + Enter)", len(fr.calls)) ++ } ++ if got, want := fr.calls[0].args, sendKeysLiteralArgs("sess-1", "hello"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("chunk 1 args = %#v, want %#v", got, want) ++ } ++ if got, want := fr.calls[1].args, sendKeysLiteralArgs("sess-1", "世"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("chunk 2 args = %#v, want %#v", got, want) ++ } ++ if got, want := fr.calls[2].args, sendKeysLiteralArgs("sess-1", "界"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("chunk 3 args = %#v, want %#v", got, want) ++ } ++ if got, want := fr.calls[3].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("Enter args = %#v, want %#v", got, want) ++ } ++} ++ ++func TestSendMessageUsesLiteralFlag(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, "Enter"); err != nil { ++ t.Fatalf("SendMessage: %v", err) ++ } ++ // First call must use -l so "Enter" is sent literally, not as a key binding. ++ if fr.calls[0].args[3] != "-l" { ++ t.Fatalf("send-keys args[3] = %q, want -l", fr.calls[0].args[3]) ++ } ++} ++ ++// -- GetOutput tests -- ++ ++func TestGetOutputValidatesLines(t *testing.T) { ++ r, _ := newTestRuntime(0) ++ _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 0) ++ if err == nil { ++ t.Fatal("GetOutput lines=0: got nil, want error") ++ } ++} ++ ++func TestGetOutputTrimsLines(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("one\ntwo\nthree\n")} ++ ++ out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 2) ++ if err != nil { ++ t.Fatalf("GetOutput: %v", err) ++ } ++ if out != "two\nthree\n" { ++ t.Fatalf("output = %q, want last two lines", out) ++ } ++} ++ ++func TestGetOutputTrimsTrailingScreenPaddingBeforeTailing(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("ready\nprompt> echo hi\nhi\n\n\n\n")} ++ ++ out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 2) ++ if err != nil { ++ t.Fatalf("GetOutput: %v", err) ++ } ++ if out != "prompt> echo hi\nhi\n" { ++ t.Fatalf("output = %q, want last non-padding lines", out) ++ } ++} ++ ++func TestGetOutputArgs(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("output\n")} ++ ++ _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 10) ++ if err != nil { ++ t.Fatalf("GetOutput: %v", err) ++ } ++ if got, want := fr.calls[0].args, capturePaneArgs("sess-1", 10); !reflect.DeepEqual(got, want) { ++ t.Fatalf("capture-pane args = %#v, want %#v", got, want) ++ } ++} ++ ++// -- AttachCommand tests -- ++ ++func TestAttachCommandReturnsExpectedArgv(t *testing.T) { ++ r := New(Options{Binary: "/usr/bin/tmux", Timeout: time.Second}) ++ argv, env, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1"}) ++ if err != nil { ++ t.Fatalf("AttachCommand: %v", err) ++ } ++ want := []string{"/usr/bin/tmux", "attach-session", "-t", "sess-1"} ++ if !reflect.DeepEqual(argv, want) { ++ t.Fatalf("argv = %#v, want %#v", argv, want) ++ } ++ if env != nil { ++ t.Fatalf("env = %#v, want nil", env) ++ } ++} ++ ++func TestAttachCommandRejectsInvalidHandle(t *testing.T) { ++ r := New(Options{}) ++ _, _, err := r.AttachCommand(ports.RuntimeHandle{ID: ""}) ++ if err == nil { ++ t.Fatal("AttachCommand empty handle: got nil, want error") ++ } ++} ++ ++// -- commandError tests -- ++ ++func TestCommandErrorUnwraps(t *testing.T) { ++ base := errors.New("base") ++ err := commandError{err: base, output: "details"} ++ if !errors.Is(err, base) { ++ t.Fatal("commandError should unwrap base error") ++ } ++ if !strings.Contains(err.Error(), "details") { ++ t.Fatalf("error = %q, want output details", err.Error()) ++ } ++} ++ ++// -- text helper tests -- ++ ++func TestChunks(t *testing.T) { ++ if got := chunks("", 5); !reflect.DeepEqual(got, []string{""}){ ++ t.Fatalf("chunks empty = %#v", got) ++ } ++ if got := chunks("hello", 10); !reflect.DeepEqual(got, []string{"hello"}) { ++ t.Fatalf("chunks fits = %#v", got) ++ } ++ // UTF-8 boundary: 世 is 3 bytes; with chunkSize=5 "hello世界" splits at 5,6,6 ++ got := chunks("hello世界", 5) ++ if len(got) != 3 { ++ t.Fatalf("chunks count = %d, want 3: %#v", len(got), got) ++ } ++ if got[0] != "hello" || got[1] != "世" || got[2] != "界" { ++ t.Fatalf("chunks = %#v, want [hello 世 界]", got) ++ } ++} ++ ++func TestTailLines(t *testing.T) { ++ if got := tailLines("a\nb\nc\n", 2); got != "b\nc\n" { ++ t.Fatalf("tailLines = %q, want b/c", got) ++ } ++ if got := tailLines("a\nb\n", 5); got != "a\nb\n" { ++ t.Fatalf("tailLines fewer = %q", got) ++ } ++ if got := tailLines("", 5); got != "" { ++ t.Fatalf("tailLines empty = %q", got) ++ } ++} ++ ++func TestTrimTrailingBlankLines(t *testing.T) { ++ if got := trimTrailingBlankLines("a\nb\n\n\n"); got != "a\nb\n" { ++ t.Fatalf("trimTrailingBlankLines = %q, want a/b", got) ++ } ++ if got := trimTrailingBlankLines(""); got != "" { ++ t.Fatalf("trimTrailingBlankLines empty = %q", got) ++ } ++} + +--- Changes --- + +.superpowers/sdd/task-1-brief.md + @@ -0,0 +1,141 @@ + +# Task 1: tmux runtime adapter + + + +## Goal + +Create a new Go package `internal/adapters/runtime/tmux` that drives agent + +sessions through the **tmux** CLI, implementing the SAME method set the existing + +zellij adapter exposes, so it is a drop-in replacement on Darwin/Linux. This task + +adds the package and its tests ONLY. It does NOT wire it into the daemon and does + +NOT delete zellij (that is Task 2). The build must stay green. + + + +Module path: `github.com/aoagents/agent-orchestrator/backend` + +Work inside: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode/backend` + + + +## The template to mirror + +The zellij adapter is your structural template. READ THESE FIRST: + +- `internal/adapters/runtime/zellij/zellij.go` (Runtime struct, Options, New, the + + `runner` interface + `execRunner`, Create/Destroy/IsAlive/SendMessage/GetOutput/ + + AttachCommand, session-name sanitization, `chunks`, `tailLines`, + + `trimTrailingBlankLines`, `validateEnvKeys`, `sortedKeys`, `commandError`). + +- `internal/adapters/runtime/zellij/commands.go` (arg builders + shell quoting). + +- `internal/adapters/runtime/zellij/zellij_test.go` (the `fakeRunner` test seam: + + `type fakeRunner struct { calls []runnerCall; outputs [][]byte; err error }`, + + injected via `r.runner = &fakeRunner{...}`). Mirror this exactly for tmux tests. + + + +Reuse the same idioms (Go style, comment density, error wrapping `fmt.Errorf("tmux + +runtime: ...: %w", err)`). Match the surrounding code. + + + +## Interface the package must satisfy + +The package's `Runtime` type must implement, with these EXACT signatures (they are + +the existing consumer contracts in the repo — verify against `internal/ports/ + +outbound.go`, `internal/terminal/attachment.go` `PTYSource`, `internal/review/ + +launcher.go` `reviewerRuntime`, `internal/daemon/lifecycle_wiring.go` + +`runtimeMessageSender`): + + + +```go + +func New(opts Options) *Runtime + +func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) + +func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error + +func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) + +func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error + +func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) + +func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) + +``` + + + +Add a compile-time assertion: `var _ ports.Runtime = (*Runtime)(nil)`. + + + +`ports.RuntimeConfig` fields: `SessionID domain.SessionID`, `WorkspacePath string`, + +`Argv []string`, `Env map[string]string`. `ports.RuntimeHandle{ ID string }`. + + + +## tmux command mapping (the substance) + +Use a `runner` interface identical in shape to zellij's (`Run(ctx, env, name, + +args...) ([]byte, error)` and `Start(env, name, args...) error`) with an + +`execRunner` default, so tests inject a `fakeRunner`. The tmux binary defaults to + +`"tmux"` (allow `Options.Binary` override; resolve via `exec.LookPath`). + + + +- **Create**: validate session id (sanitize like zellij's `SessionName`; tmux + + session names must not contain `.` or `:` — those are window/pane separators — + + and must be non-empty; reuse the same sanitize-to-`[A-Za-z0-9_-]`+hash approach). + + Validate WorkspacePath non-empty, Argv non-empty, env keys (port + + `validateEnvKeys`). Then: + + `tmux new-session -d -s -x 220 -y 50 -c ` + + where `` runs the agent then **execs a keep-alive shell so the tmux + + session survives the agent exiting** (this is the whole reason a multiplexer is + + used). Build the launch as a single shell command string passed to + + `sh -c` (or the configured shell), of the form: + + `export K=V; ...; export PATH=...; ; exec "${SHELL:-/bin/sh}" -i` + + Reuse zellij's `wrapLaunchCommandUnix` quoting approach (single-quote each env + + value and each argv word). Pass env to the agent via the exported-vars prefix + + (do NOT rely on tmux `-e`, which has its own quirks). After creating, set + + `tmux set-option -t status off` (hide the status bar in the embedded web + + terminal). Then verify liveness via IsAlive; on failure, Destroy and return an + + error (mirror zellij's create cleanup discipline). Return + + `ports.RuntimeHandle{ID: }`. NOTE: tmux needs no pane-id discovery — the + + handle is just the session id. Keep the handle format simple (the session id); + + do NOT invent a `session/pane` split unless a consumer requires it (none does — + + verify). + +- **Destroy**: `tmux kill-session -t `. Treat "session not found"/"can't find + + session" stderr on a non-zero exit as success (idempotent), mirroring zellij's + + `deleteSessionMissingOutput`. + +- **IsAlive**: `tmux has-session -t `. Exit 0 => alive. A non-zero exit whose + + output says the session/server is missing ("can't find session", "no server + + running", "error connecting") => definitively `false, nil`. Any OTHER error => + + `false, err` (a probe failure, NOT proof of death) — this distinction matters + + because the reaper feeds the lifecycle manager and must not kill a session on a + + transient probe error. Mirror zellij's IsAlive contract precisely. + +- **SendMessage**: send literal text then Enter. Use + + `tmux send-keys -t -l ` for the literal text (the `-l` flag stops + + tmux interpreting words like "Enter"/"C-c" as key names), chunked via the ported + + `chunks(message, chunkSize)` helper, then `tmux send-keys -t Enter` to + + submit. (Per the agent-orchestrator reference, multiline text can alternatively + + go via `load-buffer -`/`paste-buffer`, but `send-keys -l` chunked is simpler and + + correct — use it. Mark any simplification with a `ponytail:` comment.) + +- **GetOutput**: `tmux capture-pane -t -p -S -` (the `-S -` starts n + + lines back in history; `-p` prints to stdout). Then apply the ported + + `trimTrailingBlankLines` + `tailLines(out, lines)`. `lines <= 0` => error, like + + zellij. + +- **AttachCommand**: return argv `["tmux", "attach-session", "-t", ]` (use the + + resolved binary path as argv[0]) and a `nil` env block (no per-session socket dir + + needed for tmux — unlike zellij's Windows ConPTY env). The terminal layer's + + existing Unix `defaultSpawn` (creack/pty) will spawn this unchanged. + + + ... (41 lines truncated) + +141 -0 + +.superpowers/sdd/task-1-report.md + @@ -0,0 +1,65 @@ + +# Task 1 Report: tmux Runtime Adapter + + + +## What was built + + + +New package `backend/internal/adapters/runtime/tmux` implementing `ports.Runtime` + +via the tmux CLI. Four files were created (no existing files modified): + + + +- `backend/internal/adapters/runtime/tmux/tmux.go` - Runtime struct, Options, New, + + Create/Destroy/IsAlive/SendMessage/GetOutput/AttachCommand, session-name + + sanitization, helpers (chunks, tailLines, trimTrailingBlankLines, + + validateEnvKeys, sortedKeys, shellQuote, buildLaunchCommand, commandError). + +- `backend/internal/adapters/runtime/tmux/commands.go` - Arg builders for all tmux + + subcommands (newSessionArgs, setStatusOffArgs, killSessionArgs, hasSessionArgs, + + sendKeysLiteralArgs, sendEnterArgs, capturePaneArgs, exactSessionTarget). + +- `backend/internal/adapters/runtime/tmux/tmux_test.go` - 32 unit tests via fakeRunner. + +- `backend/internal/adapters/runtime/tmux/tmux_integration_test.go` - 2 integration + + tests gated on `exec.LookPath("tmux")`. + + + +## Design choices + + + +1. Handle format: plain session id string (no session/pane split). tmux needs no + + pane-id discovery; the handle is just `ports.RuntimeHandle{ID: }`. + + + +2. Exact session targeting: `kill-session -t =` and `has-session -t =` use + + tmux's `=` exact-name prefix (supported by session-selection commands in tmux + + 3.x) to prevent prefix matching ("foo" matching "foobar"). Commands that use + + pane-targeting syntax (set-option, send-keys, capture-pane) use a plain session + + name because they do not support the `=` prefix. + + + +3. Keep-alive shell: `buildLaunchCommand` appends `; exec ${SHELL:-/bin/sh} -i` so + + the tmux session survives agent exit (the whole reason a multiplexer is used). + + + +4. Send-keys chunking: `send-keys -t -l ` with `-l` flag sends text + + literally (tmux does not interpret "Enter", "C-c", etc. as key names). Chunked + + via ported `chunks()` helper with 16 KB default. + + + +5. `sessionMissingOutput` covers: "can't find session", "no server running", + + "error connecting", "session not found". Both `killSessionMissingOutput` and the + + IsAlive path use this to distinguish definitive-dead from probe-error. + + + +6. `AttachCommand` returns `["tmux", "attach-session", "-t", id]` with nil env + + (no per-session socket dir needed unlike zellij's Windows path). + + + +7. `ponytail:` comments mark the two deliberate simplifications: + + - send-keys -l chunked vs. load-buffer/paste-buffer (ceiling: very large + + messages are slightly slower; 16 KB default is ample for agent prompts). + + - PATH handling matches the zellij unix path. + + + +## Verification output + + + +``` + +$ cd backend && go build ./... + +# success (no output) + + + +$ go test ./internal/adapters/runtime/tmux/... -v + +# 34 tests passed (32 unit + 2 integration with real tmux 3.6b) + + + +$ go vet ./internal/adapters/runtime/tmux/... + +# no issues + +``` + + + +## Concerns + + + +None. The build is green, all 34 tests pass (including the integration tests on + +the installed tmux 3.6b), and no files outside the new package were modified. + +65 -0 + +backend/internal/adapters/runtime/tmux/commands.go + @@ -0,0 +1,64 @@ + +package tmux + + + +import "fmt" + + + +// newSessionArgs builds args for `tmux new-session -d -s -x 220 -y 50 + +// -c -c `. The shell -c form runs the launch command + +// inside the configured shell so exported env vars and quoting work correctly. + +func newSessionArgs(id, cwd, shellPath, launchCmd string) []string { + + return []string{ + + "new-session", "-d", + + "-s", id, + + "-x", "220", + + "-y", "50", + + "-c", cwd, + + shellPath, "-c", launchCmd, + + } + +} + + + +// setStatusOffArgs hides the tmux status bar for the given session. + +// set-option uses pane-targeting syntax which does not accept the `=` prefix, + +// so we pass the session name directly. + +func setStatusOffArgs(id string) []string { + + return []string{"set-option", "-t", id, "status", "off"} + +} + + + +// killSessionArgs builds args for `tmux kill-session -t =`. The `=` prefix + +// requests exact-name matching so a session "foo" does not accidentally match + +// "foobar" (tmux otherwise does unique-prefix matching). + +func killSessionArgs(id string) []string { + + return []string{"kill-session", "-t", exactSessionTarget(id)} + +} + + + +// hasSessionArgs builds args for `tmux has-session -t =`. The `=` prefix + +// requests exact-name matching (see killSessionArgs). + +func hasSessionArgs(id string) []string { + + return []string{"has-session", "-t", exactSessionTarget(id)} + +} + + + +// exactSessionTarget wraps id in tmux's exact-match prefix `=` so session- + +// selection commands (-t) target only the session with that precise name. + +// Only kill-session and has-session support this prefix; pane-targeting + +// commands (send-keys, capture-pane, set-option) use a plain session name. + +func exactSessionTarget(id string) string { + + return "=" + id + +} + + + +// sendKeysLiteralArgs builds args for `tmux send-keys -t -l `. + +// The -l flag stops tmux interpreting words like "Enter" as key names so the + +// text is sent verbatim. + +func sendKeysLiteralArgs(id, chunk string) []string { + + return []string{"send-keys", "-t", id, "-l", chunk} + +} + + + +// sendEnterArgs builds args for `tmux send-keys -t Enter` to submit the + +// queued input. + +func sendEnterArgs(id string) []string { + + return []string{"send-keys", "-t", id, "Enter"} + +} + + + +// capturePaneArgs builds args for `tmux capture-pane -t -p -S -`. + +// -p prints to stdout; -S - starts n lines back in history. + +func capturePaneArgs(id string, lines int) []string { + + return []string{"capture-pane", "-t", id, "-p", "-S", fmt.Sprintf("-%d", lines)} + +} + +64 -0 + +backend/internal/adapters/runtime/tmux/tmux.go + @@ -0,0 +1,482 @@ + +// Package tmux implements ports.Runtime using tmux sessions on Darwin/Linux. + +package tmux + + + +import ( + + "context" + + "crypto/sha256" + + "encoding/hex" + + "errors" + + "fmt" + + "os" + + "os/exec" + + "regexp" + + "sort" + + "strings" + + "time" + + "unicode/utf8" + + + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + +) + + + +const ( + + defaultTimeout = 5 * time.Second + + defaultChunkBytes = 16 * 1024 + +) + + + +var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + + + +var getenv = os.Getenv + + + +// Options configures a tmux Runtime. Every field has a sensible default (see + +// New), so the zero value is usable. + +type Options struct { + + Binary string // default "tmux" (resolved via exec.LookPath) + + Shell string // default $SHELL else /bin/sh + + Timeout time.Duration // default 5s + + ChunkSize int // default 16*1024 + +} + + + +// Runtime runs agent sessions inside tmux sessions, driving them via the tmux + +// CLI. It implements ports.Runtime. + +type Runtime struct { + + binary string + + shell string + + timeout time.Duration + + chunkSize int + + runner runner + +} + + + +var _ ports.Runtime = (*Runtime)(nil) + + + +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 = append(append([]string(nil), os.Environ()...), env...) + + return cmd.CombinedOutput() + +} + + + +func (execRunner) Start(env []string, name string, args ...string) error { + + cmd := exec.Command(name, args...) + + cmd.Env = append(append([]string(nil), os.Environ()...), env...) + + return cmd.Start() + +} + + + +// New builds a tmux Runtime, filling unset Options with defaults: binary "tmux" + +// (resolved via exec.LookPath), shell from $SHELL (else /bin/sh), and the + +// default timeout and output chunk size. + +func New(opts Options) *Runtime { + + binary := opts.Binary + + if binary == "" { + + if path, err := exec.LookPath("tmux"); err == nil { + + binary = path + + } else { + + binary = "tmux" + + } + + } + + timeout := opts.Timeout + + if timeout == 0 { + + timeout = defaultTimeout + + } + + shellPath := opts.Shell + + if shellPath == "" { + + shellPath = getenv("SHELL") + + } + + if shellPath == "" { + + shellPath = "/bin/sh" + + } + + chunkSize := opts.ChunkSize + + if chunkSize <= 0 { + + chunkSize = defaultChunkBytes + + } + + return &Runtime{ + + binary: binary, + + shell: shellPath, + ... (382 lines truncated) + +482 -0 + +backend/internal/adapters/runtime/tmux/tmux_integration_test.go + @@ -0,0 +1,141 @@ + +package tmux + + + +import ( + + "context" + + "os/exec" + + "strings" + + "testing" + + "time" + + + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + +) + + + +func TestRuntimeIntegration(t *testing.T) { + + if _, err := exec.LookPath("tmux"); err != nil { + + t.Skip("tmux unavailable") + + } + + + + ctx := context.Background() + + id := "ao_itest_tmux" + + r := New(Options{Timeout: 5 * time.Second}) + + + + // Ensure clean slate: ignore errors (session may not exist). + + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) + + + + t.Cleanup(func() { + + // Always destroy so a test failure never leaks a tmux session. + + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) + + }) + + + + h, err := r.Create(ctx, ports.RuntimeConfig{ + + SessionID: "ao_itest_tmux", + + WorkspacePath: t.TempDir(), + + // Run a trivial command then drop into an interactive shell (the keep-alive + + // exec is added by buildLaunchCommand, but we also verify here that output + + // appears). + + Argv: []string{"sh", "-c", "echo hello-from-tmux"}, + + Env: map[string]string{"AO_SESSION_ID": id}, + + }) + + if err != nil { + + t.Fatalf("Create: %v", err) + + } + + + + alive, err := r.IsAlive(ctx, h) + + if err != nil { + + t.Fatalf("IsAlive: %v", err) + + } + + if !alive { + + t.Fatal("alive = false, want true after create") + + } + + + + // Wait for the echo output to appear (the session may take a moment to + + // write it to the pane history). + + out := waitForOutput(t, r, h, "hello-from-tmux", 5*time.Second) + + if !strings.Contains(out, "hello-from-tmux") { + + t.Fatalf("output = %q, want hello-from-tmux", out) + + } + + + + // Send a command and verify it echoes back. + + if err := r.SendMessage(ctx, h, "echo hello-send"); err != nil { + + t.Fatalf("SendMessage: %v", err) + + } + + out = waitForOutput(t, r, h, "hello-send", 5*time.Second) + + if !strings.Contains(out, "hello-send") { + + t.Fatalf("output after SendMessage = %q, want hello-send", out) + + } + + + + // Destroy and verify liveness goes false. + + if err := r.Destroy(ctx, h); err != nil { + + t.Fatalf("Destroy: %v", err) + + } + + alive, err = r.IsAlive(ctx, h) + + if err != nil { + + t.Fatalf("IsAlive after destroy: %v", err) + + } + + if alive { + + t.Fatal("alive after destroy = true, want false") + + } + +} + + + +// TestRuntimeIntegrationExactSessionParsing verifies that IsAlive uses exact + +// session matching and does not treat a prefix as a live session. + +func TestRuntimeIntegrationExactSessionParsing(t *testing.T) { + + if _, err := exec.LookPath("tmux"); err != nil { + + t.Skip("tmux unavailable") + + } + + + + ctx := context.Background() + + longID := "ao_tmux_exact_long" + + prefixID := "ao_tmux_exact" + + + + r := New(Options{Timeout: 5 * time.Second}) + + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) + + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID}) + + + + t.Cleanup(func() { + + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: longID}) + + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: prefixID}) + + }) + + + + h, err := r.Create(ctx, ports.RuntimeConfig{ + ... (41 lines truncated) + +141 -0 + +backend/internal/adapters/runtime/tmux/tmux_test.go + @@ -0,0 +1,630 @@ + +package tmux + + + +import ( + + "context" + + "errors" + + "os/exec" + + "reflect" + + "strings" + + "testing" + + "time" + + + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + +) + + + +// -- fakeRunner test seam (mirrors zellij_test.go exactly) -- + + + +type fakeRunner struct { + + calls []runnerCall + + outputs [][]byte + + err error + +} + + + +type runnerCall struct { + + env []string + + name string + + args []string + +} + + + +func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { + + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) + + var out []byte + + if len(f.outputs) > 0 { + + out = f.outputs[0] + + f.outputs = f.outputs[1:] + + } + + if f.err != nil { + + return out, f.err + + } + + return out, nil + +} + + + +func (f *fakeRunner) Start(env []string, name string, args ...string) error { + + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) + + if len(f.outputs) > 0 { + + f.outputs = f.outputs[1:] + + } + + return f.err + +} + + + +// -- helpers -- + +... (more changes truncated) + +51 -0 +[full diff: rtk git diff --no-compact] diff --git a/.superpowers/sdd/task-2-brief.md b/.superpowers/sdd/task-2-brief.md new file mode 100644 index 00000000..16c23870 --- /dev/null +++ b/.superpowers/sdd/task-2-brief.md @@ -0,0 +1,142 @@ +# Task 2: runtime selection + daemon wiring + doctor/spawn hints + +## Goal +Wire the new tmux adapter (Task 1, package `internal/adapters/runtime/tmux`) into +the daemon by platform: select **tmux on Darwin/Linux**, keep **zellij on Windows** +(interim — the Windows ConPTY adapter is a later phase). Keep the build and the +whole test suite green on this Darwin machine, and keep `GOOS=windows go build` +compiling. Do NOT delete the zellij package (Windows still uses it). + +Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, +module in `backend/`. Branch `migrate-zellij-to-tmux-conpty` is checked out. +Module path prefix: `github.com/aoagents/agent-orchestrator/backend`. + +## Background you need (verified facts) +Every runtime consumer already depends on a NARROW structural interface, so they +need no change: +- `internal/terminal/attachment.go` `PTYSource` = AttachCommand + IsAlive +- `internal/daemon/lifecycle_wiring.go` `runtimeMessageSender` = SendMessage +- `internal/observe/reaper/reaper.go` `runtimeProber` = IsAlive (reaper.New takes it) +- `internal/session_manager/manager.go` `runtimeController` = Create + Destroy + (Deps.Runtime is type `runtimeController`) +- `internal/review/launcher.go` `reviewerRuntime` = Create + IsAlive + SendMessage + (NewLauncher takes it) +- `internal/daemon/lifecycle_wiring.go` `startLifecycle` takes `ports.Runtime` + +The ONLY place pinned to the concrete type is +`startSession(cfg, runtime *zellij.Runtime, ...)` at +`internal/daemon/lifecycle_wiring.go:65`, called from `daemon.go:122`. + +Both `zellij.Runtime` and `tmux.Runtime` implement the SAME superset of methods: +Create, Destroy, IsAlive, SendMessage, GetOutput, AttachCommand. + +## What to build + +### 1. New package `internal/adapters/runtime/runtimeselect` +Define the union interface that the daemon wires and that both adapters satisfy: +```go +package runtimeselect + +type Runtime interface { + ports.Runtime // Create, Destroy, IsAlive + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error + GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) + AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) +} +``` +Add a constructor that picks the backend by OS: +```go +// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows +// (the Windows ConPTY adapter lands in a later phase). log is used only for the +// Windows zellij socket-dir warning; nil is allowed. +func New(log *slog.Logger) Runtime +``` +- Non-Windows (`runtime.GOOS != "windows"`): return `tmux.New(tmux.Options{})`. +- Windows: replicate today's daemon.go behavior exactly — compute + `zellij.DefaultSocketDir()`, `os.MkdirAll(dir, 0o700)` and on error + `log.Warn(... "could not create zellij socket dir; spawns may fail" ...)` (guard + on nil log), then return `zellij.New(zellij.Options{SocketDir: dir})`. +- Add compile-time assertions: `var _ Runtime = (*tmux.Runtime)(nil)` and + `var _ Runtime = (*zellij.Runtime)(nil)`. + +Keep it minimal (ponytail): no Options struct, no registry, no env knobs the daemon +will not set. A plain `runtime.GOOS` switch is the right altitude (mirrors +agent-orchestrator's `getDefaultRuntime()`). + +### 2. Rewire `internal/daemon/daemon.go` +Replace the zellij-specific block (the `zellijSocketDir := zellij.DefaultSocketDir()` ++ MkdirAll + warn + `runtimeAdapter := zellij.New(...)`, currently lines ~89-99) +with a single `runtimeAdapter := runtimeselect.New(log)`. Update the nearby comment +so it no longer says "the Zellij runtime supplies..." — make it runtime-neutral +(e.g. "the selected runtime (tmux on macOS/Linux, zellij on Windows) supplies the +PTY-attach command and liveness"). Remove the now-unused `zellij` import from +daemon.go IF nothing else there uses it (check; `os` may also become unused — only +remove imports that are truly unused). `terminal.NewManager(runtimeAdapter, ...)`, +`newSessionMessenger(store, runtimeAdapter, ...)`, `startLifecycle(..., runtimeAdapter, ...)`, +and `startSession(cfg, runtimeAdapter, ...)` calls stay as-is (they take interfaces +or the union). + +### 3. Change `startSession` signature in `internal/daemon/lifecycle_wiring.go` +Change the param `runtime *zellij.Runtime` to `runtime runtimeselect.Runtime`. +Update the doc comment ("over the real zellij runtime" -> runtime-neutral). The body +is unchanged: it passes `runtime` into `sessionmanager.Deps{Runtime: runtime}` (the +union satisfies `runtimeController`) and `reviewcore.NewLauncher(reviewers, runtime)` +(the union satisfies `reviewerRuntime`). Remove the now-unused `zellij` import from +lifecycle_wiring.go if it becomes unused. + +### 4. Runtime-aware doctor check `internal/cli/doctor.go` +Today (~lines 294-308) it runs `zellij --version` and validates against +`zellij.CheckVersionOutput`/`zellij.RequiredVersion`. Make the tool check match the +selected runtime: +- Non-Windows: check for **tmux**. Resolve `exec.LookPath("tmux")`; run `tmux -V`; + report a pass with the version string (e.g. "tmux (version 3.6b)"). tmux has no + hard minimum version for our usage, so presence + version is enough — do NOT + invent a strict semver gate. If tmux is missing, report the existing failure + level the function uses for a missing tool, with name "tmux". +- Windows: keep the existing zellij version check unchanged. +Use the same `doctorCheck{...}` shape, `doctorSectionTools` section, and pass/fail +levels already in the file. Read the surrounding function to match its structure and +the exact way it shells out (it likely has a helper for running the tool). + +### 5. Runtime-aware attach hint `internal/cli/spawn.go` +Today (~lines 101-106) it prints `zellij attach ` plus a +ZELLIJ_SOCKET_DIR note. Make it runtime-aware: +- Non-Windows: print `tmux attach -t ` where `` is the tmux session + name for the session id. For this you need an EXPORTED sanitizer in the tmux + package: add `func SessionName(id string) string` to the tmux package (it + already sanitizes internally for Create — expose that same function, mirroring + `zellij.SessionName`). No socket-dir note is needed for tmux. +- Windows: keep the existing `zellij attach` hint (with the socket-dir note). + +### 6. Update `internal/daemon/wiring_test.go` +It currently calls `zellij.New(zellij.Options{})`, `startSession(cfg, runtime, ...)`, +`startLifecycle(ctx, store, zellij.New(...), ...)`, and `zellij.DefaultSocketDir()`. +After the signature change, `startSession` takes `runtimeselect.Runtime`. Update the +test to pass a value that satisfies it. Simplest and most faithful: construct the +real selected runtime via `runtimeselect.New(nil)` (on this Darwin box that yields +the tmux adapter, and tmux is installed), OR pass `tmux.New(tmux.Options{})` +directly. Keep the test's intent. If a sub-test asserts specifically on +`zellij.DefaultSocketDir()` semantics, keep that sub-test pointed at zellij directly +(it is testing the zellij helper, which still exists) — do not delete coverage; just +make it compile and pass. Read the whole test file and adjust only what the signature +change forces. + +## Definition of done (runnable checks — run from `backend/`) +- `go build ./...` succeeds. +- `GOOS=windows go build ./...` succeeds (Windows still compiles on the zellij path). +- `go test ./...` passes (the full backend suite). If any pre-existing test is + flaky/unrelated-failing, note it in the report; do not paper over a failure your + change caused. +- `go vet ./internal/daemon/... ./internal/cli/... ./internal/adapters/runtime/...` + is clean. + +## Hard rules / constraints +- Never use em dashes ("—") anywhere in code, comments, or commit messages. Use + periods, commas, semicolons, or parentheses. +- Do NOT delete the zellij package or change its behavior (Windows depends on it). +- Do NOT change the narrow consumer interfaces or the terminal layer. +- No new Go dependencies. go.mod unchanged. +- Keep changes minimal and on-pattern (ponytail). Mark any deliberate shortcut with + a `ponytail:` comment. +- Commit on the current branch with a clear message ending in the + `Co-Authored-By: Claude Opus 4.8 ` trailer. diff --git a/.superpowers/sdd/task-2-review-package.txt b/.superpowers/sdd/task-2-review-package.txt new file mode 100644 index 00000000..b8d0465e --- /dev/null +++ b/.superpowers/sdd/task-2-review-package.txt @@ -0,0 +1,1069 @@ +=== COMMITS === +b459abf feat(runtime): wire tmux on Darwin/Linux via runtimeselect, keep zell... + +=== STAT === +.superpowers/sdd/task-2-report.md | 95 ++++++++++++++++++++++ + .../runtime/runtimeselect/runtimeselect.go | 46 +++++++++++ + backend/internal/cli/doctor.go | 31 ++++++- + backend/internal/cli/doctor_test.go | 38 +++++---- + backend/internal/cli/spawn.go | 21 +++-- + backend/internal/daemon/daemon.go | 20 ++--- + backend/internal/daemon/lifecycle_wiring.go | 6 +- + backend/internal/daemon/wiring_test.go | 7 +- + 8 files changed, 216 insertions(+), 48 deletions(-) + +=== DIFF === +.superpowers/sdd/task-2-report.md | 95 ++++++++++++++++++++++ + .../runtime/runtimeselect/runtimeselect.go | 46 +++++++++++ + backend/internal/cli/doctor.go | 31 ++++++- + backend/internal/cli/doctor_test.go | 38 +++++---- + backend/internal/cli/spawn.go | 21 +++-- + backend/internal/daemon/daemon.go | 20 ++--- + backend/internal/daemon/lifecycle_wiring.go | 6 +- + backend/internal/daemon/wiring_test.go | 7 +- + 8 files changed, 216 insertions(+), 48 deletions(-) + +diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md +new file mode 100644 +index 0000000..2a84176 +--- /dev/null ++++ b/.superpowers/sdd/task-2-report.md +@@ -0,0 +1,95 @@ ++# Task 2 Report: runtime selection + daemon wiring + doctor/spawn hints ++ ++## Status: DONE ++ ++## Files Changed ++ ++### New file ++- `backend/internal/adapters/runtime/runtimeselect/runtimeselect.go` ++ - Defines the `Runtime` union interface (embeds `ports.Runtime`; adds `SendMessage`, `GetOutput`, `AttachCommand`). ++ - `New(log *slog.Logger) Runtime` returns `tmux.New(tmux.Options{})` on non-Windows, and replicates the old daemon zellij socket-dir setup on Windows. ++ - Compile-time assertions: `var _ Runtime = (*tmux.Runtime)(nil)` and `var _ Runtime = (*zellij.Runtime)(nil)`. ++ ++### Modified files ++ ++**`backend/internal/daemon/daemon.go`** ++- Swapped `zellij` import for `runtimeselect`. ++- Replaced the 10-line zellij socket-dir block with `runtimeAdapter := runtimeselect.New(log)`. ++- Updated the terminal-streaming comment to be runtime-neutral. ++- `os` import retained (still used by `newLogger()` for `os.Stderr`). ++ ++**`backend/internal/daemon/lifecycle_wiring.go`** ++- Swapped `zellij` import for `runtimeselect`. ++- Changed `startSession` param from `runtime *zellij.Runtime` to `runtime runtimeselect.Runtime`. ++- Updated doc comment from "over the real zellij runtime" to "over the selected runtime". ++ ++**`backend/internal/cli/doctor.go`** ++- Added `"runtime"` stdlib import. ++- Renamed call site from `c.checkZellij(ctx)` to `c.checkTerminalRuntime(ctx)`. ++- Added `checkTerminalRuntime` dispatcher: calls `checkTmux` on non-Windows, `checkZellij` on Windows. ++- Added `checkTmux`: resolves `tmux` via `LookPath`, runs `tmux -V`, reports PASS with version string; WARN for missing (matching the zellij missing-level so end-to-end `ok: true` tests remain green), FAIL if found but version command errors. ++- Kept `checkZellij` intact (Windows still uses it). ++ ++**`backend/internal/cli/spawn.go`** ++- Added `"runtime"` stdlib import and `tmux` adapter import. ++- Replaced unconditional `zellij attach` hint with a runtime-aware block: ++ - Non-Windows: `tmux attach -t ` using `tmux.SessionName(res.Session.ID)`. ++ - Windows: existing `ZELLIJ_SOCKET_DIR=... zellij attach ` hint (unchanged). ++ ++**`backend/internal/daemon/wiring_test.go`** ++- Added `runtimeselect` import. ++- `TestWiring_StartSessionBuildsSessionService`: replaced `zellij.New(zellij.Options{})` with `runtimeselect.New(nil)` so the test compiles and passes after the `startSession` signature change. ++- `TestWiring_StartLifecycleThreadsMessengerIntoLCM` and `TestDaemonZellijSocketDir_LeavesBudgetForSessionNames` still use `zellij` directly; those sub-tests are testing zellij semantics that still exist and were not changed. ++ ++**`backend/internal/cli/doctor_test.go`** ++- Renamed and rewrote the three zellij-specific tool tests to cover `checkTmux` (on Darwin): ++ - `TestDoctorChecksZellijVersion` -> `TestDoctorChecksTmuxVersion` ++ - `TestDoctorFailsUnsupportedZellijVersion` -> `TestDoctorChecksTmuxVersionFailsOnError` ++ - `TestDoctorWarnsWhenZellijMissing` -> `TestDoctorWarnsWhenTmuxMissing` ++ ++## Union Interface ++ ++```go ++type Runtime interface { ++ ports.Runtime // Create, Destroy, IsAlive ++ SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error ++ GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) ++ AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) ++} ++``` ++ ++## SessionName export ++ ++`tmux.SessionName(id string) string` was already exported in Task 1 (line 267 of `tmux.go`). No change needed. ++ ++## Handling of wiring_test.go ++ ++The only test that called `startSession` with a concrete `*zellij.Runtime` was `TestWiring_StartSessionBuildsSessionService`. It now passes `runtimeselect.New(nil)` (which returns the tmux adapter on Darwin). Tests that still use `zellij.New` directly (`startLifecycle` call and `DefaultSocketDir` assertion) were left untouched; they test zellij semantics and compile fine because the zellij import remains. ++ ++## Verification Outputs ++ ++All run from `backend/`. ++ ++### `go build ./...` ++``` ++Go build: Success ++``` ++ ++### `GOOS=windows go build ./...` ++``` ++Go build: Success ++``` ++ ++### `go test ./...` ++``` ++Go test: 1585 passed in 75 packages ++``` ++ ++### `go vet ./internal/daemon/... ./internal/cli/... ./internal/adapters/runtime/...` ++``` ++Go vet: No issues found ++``` ++ ++## Concerns ++ ++None. All checks green, no pre-existing failures, no new dependencies, go.mod unchanged. +diff --git a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go +new file mode 100644 +index 0000000..e6ca4a0 +--- /dev/null ++++ b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go +@@ -0,0 +1,46 @@ ++// Package runtimeselect picks the correct runtime backend by platform: ++// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). ++package runtimeselect ++ ++import ( ++ "context" ++ "log/slog" ++ "os" ++ "runtime" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" ++) ++ ++// Runtime is the union interface that both tmux and zellij satisfy. ++// It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods ++// the daemon wires directly. ++type Runtime interface { ++ ports.Runtime // Create, Destroy, IsAlive ++ SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error ++ GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) ++ AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) ++} ++ ++// Compile-time assertions: both adapters must implement the union interface. ++var _ Runtime = (*tmux.Runtime)(nil) ++var _ Runtime = (*zellij.Runtime)(nil) ++ ++// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. ++// log is used only for the Windows zellij socket-dir warning; nil is safe. ++func New(log *slog.Logger) Runtime { ++ if runtime.GOOS != "windows" { ++ return tmux.New(tmux.Options{}) ++ } ++ // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. ++ dir := zellij.DefaultSocketDir() ++ if dir != "" { ++ if err := os.MkdirAll(dir, 0o700); err != nil { ++ if log != nil { ++ log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) ++ } ++ } ++ } ++ return zellij.New(zellij.Options{SocketDir: dir}) ++} +diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go +index 60b0e29..c6def22 100644 +--- a/backend/internal/cli/doctor.go ++++ b/backend/internal/cli/doctor.go +@@ -10,20 +10,22 @@ import ( + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + ++ "runtime" ++ + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + ) + + type doctorLevel string + + const ( + doctorPass doctorLevel = "PASS" + doctorWarn doctorLevel = "WARN" +@@ -161,21 +163,21 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { + msg = fmt.Sprintf("%s pid=%d port=%d", msg, st.PID, st.Port) + } + if st.Error != "" { + msg += " (" + st.Error + ")" + } + checks = append(checks, doctorCheck{Level: level, Section: doctorSectionCore, Name: "daemon", Message: msg}) + } + + checks = append(checks, + c.checkGit(ctx), +- c.checkZellij(ctx), ++ c.checkTerminalRuntime(ctx), + c.checkAOBinary(), + ) + for _, harness := range doctorHarnesses { + checks = append(checks, c.checkHarness(ctx, harness)) + } + checks = append(checks, c.checkCodexLaunchFlags(ctx), c.checkGitHubToken(ctx)) + return checks + } + + // checkStore inspects the SQLite store WITHOUT opening or migrating it. The +@@ -283,20 +285,47 @@ func (c *commandContext) checkGit(ctx context.Context) doctorCheck { + cmp, err := compareDottedVersion(version, minGitVersion) + if err != nil { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version unknown: %s)", path, firstOutputLine(out))} + } + if cmp < 0 { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; AO expects >= %s for worktrees)", path, version, minGitVersion)} + } + return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; supports worktrees)", path, version)} + } + ++// checkTerminalRuntime checks for the runtime multiplexer used on this platform: ++// tmux on Darwin/Linux, zellij on Windows. ++func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { ++ if runtime.GOOS == "windows" { ++ return c.checkZellij(ctx) ++ } ++ return c.checkTmux(ctx) ++} ++ ++func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { ++ path, err := c.deps.LookPath("tmux") ++ if err != nil || path == "" { ++ return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"} ++ } ++ reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) ++ defer cancel() ++ out, err := c.deps.CommandOutput(reqCtx, path, "-V") ++ if err != nil { ++ return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s: %v", path, err)} ++ } ++ version := firstOutputLine(out) ++ if version == "" { ++ version = "version unknown" ++ } ++ return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s)", path, version)} ++} ++ + func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { + path, err := c.deps.LookPath("zellij") + if err != nil || path == "" { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "zellij", Message: "not found in PATH"} + } + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + out, err := c.deps.CommandOutput(reqCtx, path, "--version") + if err != nil { + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} +diff --git a/backend/internal/cli/doctor_test.go b/backend/internal/cli/doctor_test.go +index c1bf169..2e4acf0 100644 +--- a/backend/internal/cli/doctor_test.go ++++ b/backend/internal/cli/doctor_test.go +@@ -45,67 +45,69 @@ func TestDoctorWarnsOnUnsupportedGitVersion(t *testing.T) { + func TestDoctorFailsWhenGitMissing(t *testing.T) { + setConfigEnv(t) + c := doctorContext(t, map[string]string{}, nil) + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "git") + if check.Level != doctorFail { + t.Fatalf("git check = %+v, want FAIL", check) + } + } + +-func TestDoctorChecksZellijVersion(t *testing.T) { ++func TestDoctorChecksTmuxVersion(t *testing.T) { + setConfigEnv(t) +- c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, args ...string) ([]byte, error) { ++ c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, args ...string) ([]byte, error) { + switch name { + case "/bin/git": + return []byte("git version 2.43.0\n"), nil +- case "/bin/zellij": +- if len(args) != 1 || args[0] != "--version" { +- t.Fatalf("unexpected zellij command: %s %v", name, args) ++ case "/bin/tmux": ++ if len(args) != 1 || args[0] != "-V" { ++ t.Fatalf("unexpected tmux command: %s %v", name, args) + } +- return []byte("zellij 0.44.3\n"), nil ++ return []byte("tmux 3.3a\n"), nil + default: + t.Fatalf("unexpected command: %s %v", name, args) + return nil, nil + } + }) + +- check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") +- if check.Level != doctorPass || !strings.Contains(check.Message, "0.44.3") { +- t.Fatalf("zellij check = %+v, want PASS with version", check) ++ check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") ++ if check.Level != doctorPass || !strings.Contains(check.Message, "3.3a") { ++ t.Fatalf("tmux check = %+v, want PASS with version", check) + } + } + +-func TestDoctorFailsUnsupportedZellijVersion(t *testing.T) { ++// TestDoctorChecksTmuxVersionFailsOnError covers the case where tmux is found ++// but the version command fails. ++func TestDoctorChecksTmuxVersionFailsOnError(t *testing.T) { + setConfigEnv(t) +- c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { ++ c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "/bin/git" { + return []byte("git version 2.43.0\n"), nil + } +- return []byte("zellij 0.44.2\n"), nil ++ return nil, errors.New("exec: tmux: not found") + }) + +- check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") +- if check.Level != doctorFail || !strings.Contains(check.Message, "require >= 0.44.3") { +- t.Fatalf("zellij check = %+v, want FAIL with minimum version", check) ++ check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") ++ if check.Level != doctorFail { ++ t.Fatalf("tmux check = %+v, want FAIL on version error", check) + } + } + +-func TestDoctorWarnsWhenZellijMissing(t *testing.T) { ++func TestDoctorWarnsWhenTmuxMissing(t *testing.T) { + setConfigEnv(t) + c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { + return []byte("git version 2.43.0\n"), nil + }) + +- check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") ++ check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") + if check.Level != doctorWarn { +- t.Fatalf("zellij check = %+v, want WARN", check) ++ t.Fatalf("tmux check = %+v, want WARN", check) + } + } + + func TestDoctorChecksHarnessVersions(t *testing.T) { + setConfigEnv(t) + cmdPath := map[string]string{ + "git": "/bin/git", + "claude": "/bin/claude", + "codex": "/bin/codex", + } +diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go +index b5cc717..ae7bac4 100644 +--- a/backend/internal/cli/spawn.go ++++ b/backend/internal/cli/spawn.go +@@ -1,20 +1,22 @@ + package cli + + import ( + "context" + "fmt" + "net/url" ++ "runtime" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + ) + + type spawnOptions struct { + project string + harness string + branch string + prompt string + issue string + claimPR string +@@ -90,28 +92,31 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { + } + } + out := cmd.OutOrStdout() + claimLabel := "" + if claimed != "" { + claimLabel = fmt.Sprintf(" (claimed %s)", claimed) + } + if _, err := fmt.Fprintf(out, "spawned session %s (%s)%s\n", res.Session.ID, res.Session.Status, claimLabel); err != nil { + return err + } +- // The daemon runs zellij under a short, non-default socket dir (see +- // zellij.DefaultSocketDir), so a plain `zellij attach` wouldn't find +- // the session — prefix the env so the hint is copy-pasteable. Use the +- // sanitised name zellij actually registers (zellij.SessionName): a long +- // session id maps to a different name than the raw id. +- attach := fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) +- if dir := zellij.DefaultSocketDir(); dir != "" { +- attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) ++ // Print a copy-pasteable attach hint for the selected runtime. ++ // On Darwin/Linux: tmux attach-session using the sanitised session name. ++ // On Windows: zellij attach with the short socket dir prefix. ++ var attach string ++ if runtime.GOOS != "windows" { ++ attach = fmt.Sprintf("tmux attach -t %s", tmux.SessionName(res.Session.ID)) ++ } else { ++ attach = fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) ++ if dir := zellij.DefaultSocketDir(); dir != "" { ++ attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) ++ } + } + _, err := fmt.Fprintf(out, "attach with: %s\n", attach) + return err + }, + } + f := cmd.Flags() + // --agent is an alias for --harness so the more intuitive `ao spawn --agent + // droid` works identically; both resolve to the same harness flag. + f.SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName { + if name == "agent" { +diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go +index 59791c8..2bdab64 100644 +--- a/backend/internal/daemon/daemon.go ++++ b/backend/internal/daemon/daemon.go +@@ -6,21 +6,21 @@ package daemon + import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + +- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/notify" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/preview" + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" + "github.com/aoagents/agent-orchestrator/backend/internal/terminal" +@@ -75,35 +75,25 @@ func Run() error { + // signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the + // graceful shutdown inside Server.Run and stops the background goroutines. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + cdcPipe, err := startCDC(ctx, store, log) + if err != nil { + return err + } + +- // Terminal streaming: the Zellij runtime supplies the PTY-attach command and +- // liveness; the CDC broadcaster feeds the session-state channel. The manager ++ // Terminal streaming: the selected runtime (tmux on macOS/Linux, zellij on Windows) supplies the ++ // PTY-attach command and liveness; the CDC broadcaster feeds the session-state channel. The manager + // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow +- // through the CDC change_log — only session-state events do. +- // zellij's default socket dir is too long on macOS for long session ids +- // (see zellij.DefaultSocketDir); use a short, stable one and ensure it exists. +- zellijSocketDir := zellij.DefaultSocketDir() +- if zellijSocketDir != "" { +- if err := os.MkdirAll(zellijSocketDir, 0o700); err != nil { +- // Don't abort startup, but surface it: every spawn's zellij session +- // would otherwise fail later with an opaque socket-bind error. +- log.Warn("could not create zellij socket dir; spawns may fail", "dir", zellijSocketDir, "error", err) +- } +- } +- runtimeAdapter := zellij.New(zellij.Options{SocketDir: zellijSocketDir}) ++ // through the CDC change_log -- only session-state events do. ++ runtimeAdapter := runtimeselect.New(log) + termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) + defer termMgr.Close() + + // The agent messenger sends validated user input to the session's live + // zellij pane. Keep this path small until durable inbox semantics are needed. + // Built before the Lifecycle Manager so the LCM can use it for SCM-driven + // agent nudges (CI failure, review feedback, merge conflict). + messenger := newSessionMessenger(store, runtimeAdapter, log) + notificationHub := notify.NewHub() + notifier := notificationsvc.New(notificationsvc.Deps{Store: store}) +diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go +index 3bf5ff7..5d21160 100644 +--- a/backend/internal/daemon/lifecycle_wiring.go ++++ b/backend/internal/daemon/lifecycle_wiring.go +@@ -3,21 +3,21 @@ package daemon + import ( + "context" + "fmt" + "log/slog" + "path/filepath" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitydispatch" + agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/reviewer" +- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" + reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" +@@ -52,24 +52,24 @@ func startLifecycle(ctx context.Context, store *sqlite.Store, runtime ports.Runt + // Stop waits for the reaper goroutine to exit. The caller must cancel the ctx + // passed to startLifecycle before calling Stop. + func (l *lifecycleStack) Stop() { + <-l.reaperDone + if l.scmDone != nil { + <-l.scmDone + } + } + + // startSession builds the controller-facing session service: a session manager +-// over the real zellij runtime, a per-session gitworktree workspace, the shared ++// over the selected runtime, a per-session gitworktree workspace, the shared + // store + LCM, the per-session agent resolver, and the agent messenger. The + // returned service is mounted at httpd APIDeps.Sessions. +-func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { ++func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { + defaultAgent := cfg.Agent + if defaultAgent == "" { + defaultAgent = config.DefaultAgent + } + agents, err := buildAgentResolver(defaultAgent, log) + if err != nil { + return nil, nil, err + } + ws, err := gitworktree.New(gitworktree.Options{ + // Per-session worktrees live under the data dir, so a single AO_DATA_DIR +diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go +index a3acd55..21bd248 100644 +--- a/backend/internal/daemon/wiring_test.go ++++ b/backend/internal/daemon/wiring_test.go +@@ -3,20 +3,21 @@ package daemon + import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + telemetryadapter "github.com/aoagents/agent-orchestrator/backend/internal/adapters/telemetry" + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" + ) +@@ -142,23 +143,23 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + lcm := lifecycle.New(store, nil) + cfg := config.Config{DataDir: t.TempDir()} + +- runtime := zellij.New(zellij.Options{}) +- messenger := newSessionMessenger(store, runtime, log) +- svc, reviewSvc, err := startSession(cfg, runtime, store, lcm, messenger, telemetryadapter.NoopSink{}, log) ++ rt := runtimeselect.New(nil) ++ messenger := newSessionMessenger(store, rt, log) ++ svc, reviewSvc, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) + if err != nil { + t.Fatalf("startSession: %v", err) + } + if svc == nil { + t.Fatal("startSession returned nil session service") + } + if reviewSvc == nil { + t.Fatal("startSession returned nil review service") + } + } + +--- Changes --- + +.superpowers/sdd/task-2-report.md + @@ -0,0 +1,95 @@ + +# Task 2 Report: runtime selection + daemon wiring + doctor/spawn hints + + + +## Status: DONE + + + +## Files Changed + + + +### New file + +- `backend/internal/adapters/runtime/runtimeselect/runtimeselect.go` + + - Defines the `Runtime` union interface (embeds `ports.Runtime`; adds `SendMessage`, `GetOutput`, `AttachCommand`). + + - `New(log *slog.Logger) Runtime` returns `tmux.New(tmux.Options{})` on non-Windows, and replicates the old daemon zellij socket-dir setup on Windows. + + - Compile-time assertions: `var _ Runtime = (*tmux.Runtime)(nil)` and `var _ Runtime = (*zellij.Runtime)(nil)`. + + + +### Modified files + + + +**`backend/internal/daemon/daemon.go`** + +- Swapped `zellij` import for `runtimeselect`. + +- Replaced the 10-line zellij socket-dir block with `runtimeAdapter := runtimeselect.New(log)`. + +- Updated the terminal-streaming comment to be runtime-neutral. + +- `os` import retained (still used by `newLogger()` for `os.Stderr`). + + + +**`backend/internal/daemon/lifecycle_wiring.go`** + +- Swapped `zellij` import for `runtimeselect`. + +- Changed `startSession` param from `runtime *zellij.Runtime` to `runtime runtimeselect.Runtime`. + +- Updated doc comment from "over the real zellij runtime" to "over the selected runtime". + + + +**`backend/internal/cli/doctor.go`** + +- Added `"runtime"` stdlib import. + +- Renamed call site from `c.checkZellij(ctx)` to `c.checkTerminalRuntime(ctx)`. + +- Added `checkTerminalRuntime` dispatcher: calls `checkTmux` on non-Windows, `checkZellij` on Windows. + +- Added `checkTmux`: resolves `tmux` via `LookPath`, runs `tmux -V`, reports PASS with version string; WARN for missing (matching the zellij missing-level so end-to-end `ok: true` tests remain green), FAIL if found but version command errors. + +- Kept `checkZellij` intact (Windows still uses it). + + + +**`backend/internal/cli/spawn.go`** + +- Added `"runtime"` stdlib import and `tmux` adapter import. + +- Replaced unconditional `zellij attach` hint with a runtime-aware block: + + - Non-Windows: `tmux attach -t ` using `tmux.SessionName(res.Session.ID)`. + + - Windows: existing `ZELLIJ_SOCKET_DIR=... zellij attach ` hint (unchanged). + + + +**`backend/internal/daemon/wiring_test.go`** + +- Added `runtimeselect` import. + +- `TestWiring_StartSessionBuildsSessionService`: replaced `zellij.New(zellij.Options{})` with `runtimeselect.New(nil)` so the test compiles and passes after the `startSession` signature change. + +- `TestWiring_StartLifecycleThreadsMessengerIntoLCM` and `TestDaemonZellijSocketDir_LeavesBudgetForSessionNames` still use `zellij` directly; those sub-tests are testing zellij semantics that still exist and were not changed. + + + +**`backend/internal/cli/doctor_test.go`** + +- Renamed and rewrote the three zellij-specific tool tests to cover `checkTmux` (on Darwin): + + - `TestDoctorChecksZellijVersion` -> `TestDoctorChecksTmuxVersion` + + - `TestDoctorFailsUnsupportedZellijVersion` -> `TestDoctorChecksTmuxVersionFailsOnError` + + - `TestDoctorWarnsWhenZellijMissing` -> `TestDoctorWarnsWhenTmuxMissing` + + + +## Union Interface + + + +```go + +type Runtime interface { + + ports.Runtime // Create, Destroy, IsAlive + + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error + + GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) + + AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) + +} + +``` + + + +## SessionName export + + + +`tmux.SessionName(id string) string` was already exported in Task 1 (line 267 of `tmux.go`). No change needed. + + + +## Handling of wiring_test.go + + + +The only test that called `startSession` with a concrete `*zellij.Runtime` was `TestWiring_StartSessionBuildsSessionService`. It now passes `runtimeselect.New(nil)` (which returns the tmux adapter on Darwin). Tests that still use `zellij.New` directly (`startLifecycle` call and `DefaultSocketDir` assertion) were left untouched; they test zellij semantics and compile fine because the zellij import remains. + + + +## Verification Outputs + + + +All run from `backend/`. + + + +### `go build ./...` + +``` + +Go build: Success + +``` + + + +### `GOOS=windows go build ./...` + +``` + +Go build: Success + +``` + + + +### `go test ./...` + +``` + +Go test: 1585 passed in 75 packages + +``` + + + +### `go vet ./internal/daemon/... ./internal/cli/... ./internal/adapters/runtime/...` + +``` + +Go vet: No issues found + +``` + + + +## Concerns + + + +None. All checks green, no pre-existing failures, no new dependencies, go.mod unchanged. + +95 -0 + +backend/internal/adapters/runtime/runtimeselect/runtimeselect.go + @@ -0,0 +1,46 @@ + +// Package runtimeselect picks the correct runtime backend by platform: + +// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). + +package runtimeselect + + + +import ( + + "context" + + "log/slog" + + "os" + + "runtime" + + + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + +) + + + +// Runtime is the union interface that both tmux and zellij satisfy. + +// It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods + +// the daemon wires directly. + +type Runtime interface { + + ports.Runtime // Create, Destroy, IsAlive + + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error + + GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) + + AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) + +} + + + +// Compile-time assertions: both adapters must implement the union interface. + +var _ Runtime = (*tmux.Runtime)(nil) + +var _ Runtime = (*zellij.Runtime)(nil) + + + +// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. + +// log is used only for the Windows zellij socket-dir warning; nil is safe. + +func New(log *slog.Logger) Runtime { + + if runtime.GOOS != "windows" { + + return tmux.New(tmux.Options{}) + + } + + // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. + + dir := zellij.DefaultSocketDir() + + if dir != "" { + + if err := os.MkdirAll(dir, 0o700); err != nil { + + if log != nil { + + log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) + + } + + } + + } + + return zellij.New(zellij.Options{SocketDir: dir}) + +} + +46 -0 + +backend/internal/cli/doctor.go + @@ -10,20 +10,22 @@ import ( + + "runtime" + + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + ) + + type doctorLevel string + + const ( + doctorPass doctorLevel = "PASS" + doctorWarn doctorLevel = "WARN" + @@ -161,21 +163,21 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { + - c.checkZellij(ctx), + + c.checkTerminalRuntime(ctx), + c.checkAOBinary(), + ) + for _, harness := range doctorHarnesses { + checks = append(checks, c.checkHarness(ctx, harness)) + } + checks = append(checks, c.checkCodexLaunchFlags(ctx), c.checkGitHubToken(ctx)) + return checks + } + + // checkStore inspects the SQLite store WITHOUT opening or migrating it. The + @@ -283,20 +285,47 @@ func (c *commandContext) checkGit(ctx context.Context) doctorCheck { + +// checkTerminalRuntime checks for the runtime multiplexer used on this platform: + +// tmux on Darwin/Linux, zellij on Windows. + +func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { + + if runtime.GOOS == "windows" { + + return c.checkZellij(ctx) + + } + + return c.checkTmux(ctx) + +} + + + +func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { + + path, err := c.deps.LookPath("tmux") + + if err != nil || path == "" { + + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"} + + } + + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + + defer cancel() + + out, err := c.deps.CommandOutput(reqCtx, path, "-V") + + if err != nil { + + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s: %v", path, err)} + + } + + version := firstOutputLine(out) + + if version == "" { + + version = "version unknown" + + } + + return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s)", path, version)} + +} + + + func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { + path, err := c.deps.LookPath("zellij") + if err != nil || path == "" { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "zellij", Message: "not found in PATH"} + } + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + out, err := c.deps.CommandOutput(reqCtx, path, "--version") + if err != nil { + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} + +30 -1 + +backend/internal/cli/doctor_test.go + @@ -45,67 +45,69 @@ func TestDoctorWarnsOnUnsupportedGitVersion(t *testing.T) { + -func TestDoctorChecksZellijVersion(t *testing.T) { + +func TestDoctorChecksTmuxVersion(t *testing.T) { + setConfigEnv(t) + - c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, args ...string) ([]byte, error) { + + c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, args ...string) ([]byte, error) { + switch name { + case "/bin/git": + return []byte("git version 2.43.0\n"), nil + - case "/bin/zellij": + - if len(args) != 1 || args[0] != "--version" { + - t.Fatalf("unexpected zellij command: %s %v", name, args) + + case "/bin/tmux": + + if len(args) != 1 || args[0] != "-V" { + + t.Fatalf("unexpected tmux command: %s %v", name, args) + } + - return []byte("zellij 0.44.3\n"), nil + + return []byte("tmux 3.3a\n"), nil + default: + t.Fatalf("unexpected command: %s %v", name, args) + return nil, nil + } + }) + + - check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") + - if check.Level != doctorPass || !strings.Contains(check.Message, "0.44.3") { + - t.Fatalf("zellij check = %+v, want PASS with version", check) + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") + + if check.Level != doctorPass || !strings.Contains(check.Message, "3.3a") { + + t.Fatalf("tmux check = %+v, want PASS with version", check) + } + } + + -func TestDoctorFailsUnsupportedZellijVersion(t *testing.T) { + +// TestDoctorChecksTmuxVersionFailsOnError covers the case where tmux is found + +// but the version command fails. + +func TestDoctorChecksTmuxVersionFailsOnError(t *testing.T) { + setConfigEnv(t) + - c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { + + c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "/bin/git" { + return []byte("git version 2.43.0\n"), nil + } + - return []byte("zellij 0.44.2\n"), nil + + return nil, errors.New("exec: tmux: not found") + }) + + - check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") + - if check.Level != doctorFail || !strings.Contains(check.Message, "require >= 0.44.3") { + - t.Fatalf("zellij check = %+v, want FAIL with minimum version", check) + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") + + if check.Level != doctorFail { + + t.Fatalf("tmux check = %+v, want FAIL on version error", check) + } + } + + -func TestDoctorWarnsWhenZellijMissing(t *testing.T) { + +func TestDoctorWarnsWhenTmuxMissing(t *testing.T) { + setConfigEnv(t) + c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { + return []byte("git version 2.43.0\n"), nil + }) + + - check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") + if check.Level != doctorWarn { + - t.Fatalf("zellij check = %+v, want WARN", check) + + t.Fatalf("tmux check = %+v, want WARN", check) + } + } + + func TestDoctorChecksHarnessVersions(t *testing.T) { + setConfigEnv(t) + cmdPath := map[string]string{ + "git": "/bin/git", + "claude": "/bin/claude", + "codex": "/bin/codex", + } + +20 -18 + +backend/internal/cli/spawn.go + @@ -1,20 +1,22 @@ + + "runtime" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + ) + + type spawnOptions struct { + project string + harness string + branch string + prompt string + issue string + claimPR string + @@ -90,28 +92,31 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { + - // The daemon runs zellij under a short, non-default socket dir (see + - // zellij.DefaultSocketDir), so a plain `zellij attach` wouldn't find + - // the session — prefix the env so the hint is copy-pasteable. Use the + - // sanitised name zellij actually registers (zellij.SessionName): a long + - // session id maps to a different name than the raw id. + - attach := fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) + - if dir := zellij.DefaultSocketDir(); dir != "" { + - attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) + + // Print a copy-pasteable attach hint for the selected runtime. + + // On Darwin/Linux: tmux attach-session using the sanitised session name. + + // On Windows: zellij attach with the short socket dir prefix. + + var attach string + + if runtime.GOOS != "windows" { + + attach = fmt.Sprintf("tmux attach -t %s", tmux.SessionName(res.Session.ID)) + + } else { + + attach = fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) + + if dir := zellij.DefaultSocketDir(); dir != "" { + + attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) + + } + } + _, err := fmt.Fprintf(out, "attach with: %s\n", attach) + return err + }, + } + f := cmd.Flags() + // --agent is an alias for --harness so the more intuitive `ao spawn --agent + // droid` works identically; both resolve to the same harness flag. + f.SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName { + if name == "agent" { + +13 -8 + +backend/internal/daemon/daemon.go + @@ -6,21 +6,21 @@ package daemon + - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/notify" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/preview" + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" + "github.com/aoagents/agent-orchestrator/backend/internal/terminal" + @@ -75,35 +75,25 @@ func Run() error { + - // Terminal streaming: the Zellij runtime supplies the PTY-attach command and + - // liveness; the CDC broadcaster feeds the session-state channel. The manager + + // Terminal streaming: the selected runtime (tmux on macOS/Linux, zellij on Windows) supplies the + + // PTY-attach command and liveness; the CDC broadcaster feeds the session-state channel. The manager + // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow + - // through the CDC change_log — only session-state events do. + - // zellij's default socket dir is too long on macOS for long session ids + - // (see zellij.DefaultSocketDir); use a short, stable one and ensure it exists. + - zellijSocketDir := zellij.DefaultSocketDir() + - if zellijSocketDir != "" { + - if err := os.MkdirAll(zellijSocketDir, 0o700); err != nil { + - // Don't abort startup, but surface it: every spawn's zellij session + - // would otherwise fail later with an opaque socket-bind error. + - log.Warn("could not create zellij socket dir; spawns may fail", "dir", zellijSocketDir, "error", err) + - } + - } + - runtimeAdapter := zellij.New(zellij.Options{SocketDir: zellijSocketDir}) + + // through the CDC change_log -- only session-state events do. + + runtimeAdapter := runtimeselect.New(log) + termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) + defer termMgr.Close() + + // The agent messenger sends validated user input to the session's live + // zellij pane. Keep this path small until durable inbox semantics are needed. + // Built before the Lifecycle Manager so the LCM can use it for SCM-driven + // agent nudges (CI failure, review feedback, merge conflict). + messenger := newSessionMessenger(store, runtimeAdapter, log) + notificationHub := notify.NewHub() + notifier := notificationsvc.New(notificationsvc.Deps{Store: store}) + +5 -15 + +backend/internal/daemon/lifecycle_wiring.go + @@ -3,21 +3,21 @@ package daemon + - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" + reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" + @@ -52,24 +52,24 @@ func startLifecycle(ctx context.Context, store *sqlite.Store, runtime ports.Runt + -// over the real zellij runtime, a per-session gitworktree workspace, the shared + +// over the selected runtime, a per-session gitworktree workspace, the shared + // store + LCM, the per-session agent resolver, and the agent messenger. The + // returned service is mounted at httpd APIDeps.Sessions. + -func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { + +func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { + defaultAgent := cfg.Agent + if defaultAgent == "" { + defaultAgent = config.DefaultAgent + } + agents, err := buildAgentResolver(defaultAgent, log) + if err != nil { + return nil, nil, err + } + ws, err := gitworktree.New(gitworktree.Options{ + // Per-session worktrees live under the data dir, so a single AO_DATA_DIR + +3 -3 + +backend/internal/daemon/wiring_test.go + @@ -3,20 +3,21 @@ package daemon + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + telemetryadapter "github.com/aoagents/agent-orchestrator/backend/internal/adapters/telemetry" + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" + ) + @@ -142,23 +143,23 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { + - runtime := zellij.New(zellij.Options{}) + - messenger := newSessionMessenger(store, runtime, log) + - svc, reviewSvc, err := startSession(cfg, runtime, store, lcm, messenger, telemetryadapter.NoopSink{}, log) + + rt := runtimeselect.New(nil) + + messenger := newSessionMessenger(store, rt, log) + + svc, reviewSvc, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) + if err != nil { + t.Fatalf("startSession: %v", err) + } + if svc == nil { + t.Fatal("startSession returned nil session service") + } + if reviewSvc == nil { + t.Fatal("startSession returned nil review service") + } + } + +4 -3 diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go index c6def22b..a52fa521 100644 --- a/backend/internal/cli/doctor.go +++ b/backend/internal/cli/doctor.go @@ -11,14 +11,13 @@ import ( "os" "path/filepath" "regexp" + "runtime" "strconv" "strings" "time" "github.com/spf13/cobra" - "runtime" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" "github.com/aoagents/agent-orchestrator/backend/internal/config" diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 2bdab641..29ca5ef6 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -91,7 +91,7 @@ func Run() error { defer termMgr.Close() // The agent messenger sends validated user input to the session's live - // zellij pane. Keep this path small until durable inbox semantics are needed. + // runtime pane. Keep this path small until durable inbox semantics are needed. // Built before the Lifecycle Manager so the LCM can use it for SCM-driven // agent nudges (CI failure, review feedback, merge conflict). messenger := newSessionMessenger(store, runtimeAdapter, log) @@ -106,7 +106,7 @@ func Run() error { lcStack.scmDone = startSCMObserver(ctx, store, lcStack.LCM, log) // Wire the controller-facing session service over the same store + LCM, the - // zellij runtime, a gitworktree workspace, the per-session agent resolver + // selected runtime, a gitworktree workspace, the per-session agent resolver // (AO_AGENT validated here for compatibility), and the agent messenger, then mount it // on the API. sessionSvc, reviewSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, telemetrySink, log) From 926ee312a922d2ea3a8ea358d7788a25842cc29c Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 23 Jun 2026 22:09:02 +0530 Subject: [PATCH 05/60] refactor(tmux): drop unused runner.Start seam tmux creates sessions detached via new-session -d, so the Start method (carried over from the zellij runner shape, where it backs the Windows fire-and-forget spawn) is never called. Remove it from the interface and its implementations to shrink the seam. Co-Authored-By: Claude Opus 4.8 --- backend/internal/adapters/runtime/tmux/tmux.go | 7 ------- .../internal/adapters/runtime/tmux/tmux_test.go | 14 -------------- 2 files changed, 21 deletions(-) diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go index be6e2fde..90235ca1 100644 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -51,7 +51,6 @@ var _ ports.Runtime = (*Runtime)(nil) 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{} @@ -62,12 +61,6 @@ func (execRunner) Run(ctx context.Context, env []string, name string, args ...st return cmd.CombinedOutput() } -func (execRunner) Start(env []string, name string, args ...string) error { - cmd := exec.Command(name, args...) - cmd.Env = append(append([]string(nil), os.Environ()...), env...) - return cmd.Start() -} - // New builds a tmux Runtime, filling unset Options with defaults: binary "tmux" // (resolved via exec.LookPath), shell from $SHELL (else /bin/sh), and the // default timeout and output chunk size. diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go index 72b165b9..07003d18 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -40,13 +40,6 @@ func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...s return out, nil } -func (f *fakeRunner) Start(env []string, name string, args ...string) error { - f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) - if len(f.outputs) > 0 { - f.outputs = f.outputs[1:] - } - return f.err -} // -- helpers -- @@ -320,13 +313,6 @@ func (f *fakeRunnerSelectiveErr) Run(_ context.Context, env []string, name strin return out, nil } -func (f *fakeRunnerSelectiveErr) Start(env []string, name string, args ...string) error { - f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) - if len(f.outputs) > 0 { - f.outputs = f.outputs[1:] - } - return nil -} // -- Destroy tests -- From 1050542756ea4d65049ec39b47e276c9625ed376 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 23 Jun 2026 23:01:47 +0530 Subject: [PATCH 06/60] feat(conpty): add protocol codec and output ring buffer (pure Go, OS-agnostic) Ports the ConPTY named-pipe binary framing protocol and rolling output buffer from pty-host.ts to Go. Implements EncodeMessage, MessageParser (handles arbitrary chunk boundaries, payload copy guarantee), and Ring (MaxOutputLines=1000, ANSI-safe, concurrent Append+Snapshot). All 15 unit tests pass on Darwin; GOOS=windows build is also clean. Co-Authored-By: Claude Opus 4.8 --- .../internal/adapters/runtime/conpty/proto.go | 89 +++++++++ .../adapters/runtime/conpty/proto_test.go | 181 ++++++++++++++++++ .../internal/adapters/runtime/conpty/ring.go | 83 ++++++++ .../adapters/runtime/conpty/ring_test.go | 125 ++++++++++++ 4 files changed, 478 insertions(+) create mode 100644 backend/internal/adapters/runtime/conpty/proto.go create mode 100644 backend/internal/adapters/runtime/conpty/proto_test.go create mode 100644 backend/internal/adapters/runtime/conpty/ring.go create mode 100644 backend/internal/adapters/runtime/conpty/ring_test.go diff --git a/backend/internal/adapters/runtime/conpty/proto.go b/backend/internal/adapters/runtime/conpty/proto.go new file mode 100644 index 00000000..fa47df34 --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/proto.go @@ -0,0 +1,89 @@ +// Package conpty implements the Windows ConPTY runtime adapter for agent sessions. +// This file contains the OS-agnostic binary framing protocol codec used by the +// named-pipe protocol between pty-host.js and this Go client. +// +// Frame layout: [1-byte type][4-byte big-endian length][payload] +package conpty + +import "encoding/binary" + +// Message type constants. Values must match pty-host.ts MSG_* constants exactly. +const ( + MsgTerminalData byte = 0x01 // host -> client: raw PTY output + MsgTerminalInput byte = 0x02 // client -> host: raw keystrokes + MsgResize byte = 0x03 // client -> host: JSON {cols, rows} + MsgGetOutputReq byte = 0x04 // client -> host: JSON {lines} + MsgGetOutputRes byte = 0x05 // host -> client: UTF-8 text + MsgStatusReq byte = 0x06 // client -> host: empty + MsgStatusRes byte = 0x07 // host -> client: JSON {alive, pid, exitCode?} + MsgKillReq byte = 0x08 // client -> host: empty +) + +// JSON payload structs shared with later tasks (kept minimal). + +// ResizePayload is the JSON body for MsgResize. +type ResizePayload struct { + Cols int `json:"cols"` + Rows int `json:"rows"` +} + +// StatusPayload is the JSON body for MsgStatusRes. +type StatusPayload struct { + Alive bool `json:"alive"` + PID int `json:"pid"` + ExitCode *int `json:"exitCode,omitempty"` +} + +// GetOutputReq is the JSON body for MsgGetOutputReq. +type GetOutputReq struct { + Lines int `json:"lines"` +} + +// EncodeMessage encodes a single frame into the binary protocol format. +// It allocates a fresh slice of exactly 5+len(payload) bytes. +func EncodeMessage(msgType byte, payload []byte) []byte { + frame := make([]byte, 5+len(payload)) + frame[0] = msgType + binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload))) + copy(frame[5:], payload) + return frame +} + +// MessageParser is a streaming parser for the binary framing protocol. +// It accumulates arbitrary-sized chunks from a pipe/socket stream and fires +// onMessage exactly once per complete frame, regardless of chunk boundaries. +// Safe to call Feed from a single goroutine; not concurrency-safe itself. +type MessageParser struct { + buf []byte + onMessage func(msgType byte, payload []byte) +} + +// NewMessageParser returns a parser that calls onMessage for each complete frame. +// onMessage receives a COPY of the payload so callers may retain it safely. +func NewMessageParser(onMessage func(msgType byte, payload []byte)) *MessageParser { + return &MessageParser{onMessage: onMessage} +} + +// Feed appends chunk to the internal buffer and dispatches all complete frames. +// It matches the semantics of MessageParser.feed in pty-host.ts exactly: +// arbitrary chunk boundaries and multiple frames per chunk are both handled. +func (p *MessageParser) Feed(chunk []byte) { + p.buf = append(p.buf, chunk...) + + for len(p.buf) >= 5 { + payloadLen := binary.BigEndian.Uint32(p.buf[1:5]) + frameLen := 5 + int(payloadLen) + if len(p.buf) < frameLen { + break + } + + msgType := p.buf[0] + // ponytail: explicit copy so callers that retain the slice are not + // corrupted when p.buf grows/reallocates on a later Feed call. + payload := make([]byte, payloadLen) + copy(payload, p.buf[5:frameLen]) + + p.buf = p.buf[frameLen:] + p.onMessage(msgType, payload) + } +} diff --git a/backend/internal/adapters/runtime/conpty/proto_test.go b/backend/internal/adapters/runtime/conpty/proto_test.go new file mode 100644 index 00000000..83cc7487 --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/proto_test.go @@ -0,0 +1,181 @@ +package conpty + +import ( + "bytes" + "encoding/binary" + "testing" +) + +// TestEncodeMessage verifies the 5-byte header and payload are written correctly. +func TestEncodeMessage(t *testing.T) { + payload := []byte("hello") + frame := EncodeMessage(MsgTerminalData, payload) + + if len(frame) != 5+len(payload) { + t.Fatalf("frame len = %d, want %d", len(frame), 5+len(payload)) + } + if frame[0] != MsgTerminalData { + t.Errorf("type byte = 0x%02x, want 0x%02x", frame[0], MsgTerminalData) + } + gotLen := binary.BigEndian.Uint32(frame[1:5]) + if int(gotLen) != len(payload) { + t.Errorf("length field = %d, want %d", gotLen, len(payload)) + } + if !bytes.Equal(frame[5:], payload) { + t.Errorf("payload = %q, want %q", frame[5:], payload) + } +} + +// TestEncodeMessageZeroPayload verifies a zero-length payload encodes correctly. +func TestEncodeMessageZeroPayload(t *testing.T) { + frame := EncodeMessage(MsgStatusReq, nil) + if len(frame) != 5 { + t.Fatalf("frame len = %d, want 5", len(frame)) + } + if frame[0] != MsgStatusReq { + t.Errorf("type byte = 0x%02x, want 0x%02x", frame[0], MsgStatusReq) + } + if got := binary.BigEndian.Uint32(frame[1:5]); got != 0 { + t.Errorf("length field = %d, want 0", got) + } +} + +// collected accumulates (type, payload) pairs received by a MessageParser. +type collected struct { + typ byte + payload []byte +} + +func collect(frames *[]collected) func(byte, []byte) { + return func(typ byte, payload []byte) { + *frames = append(*frames, collected{typ, payload}) + } +} + +// TestParserSingleFrame feeds one complete frame and expects one callback. +func TestParserSingleFrame(t *testing.T) { + var got []collected + p := NewMessageParser(collect(&got)) + + p.Feed(EncodeMessage(MsgTerminalData, []byte("hi"))) + + if len(got) != 1 { + t.Fatalf("got %d messages, want 1", len(got)) + } + if got[0].typ != MsgTerminalData { + t.Errorf("type = 0x%02x, want 0x%02x", got[0].typ, MsgTerminalData) + } + if !bytes.Equal(got[0].payload, []byte("hi")) { + t.Errorf("payload = %q, want %q", got[0].payload, "hi") + } +} + +// TestParserTwoFramesOneChunk feeds two frames concatenated and expects two callbacks. +func TestParserTwoFramesOneChunk(t *testing.T) { + var got []collected + p := NewMessageParser(collect(&got)) + + chunk := append(EncodeMessage(MsgTerminalData, []byte("frame1")), + EncodeMessage(MsgTerminalInput, []byte("frame2"))...) + p.Feed(chunk) + + if len(got) != 2 { + t.Fatalf("got %d messages, want 2", len(got)) + } + if got[0].typ != MsgTerminalData || string(got[0].payload) != "frame1" { + t.Errorf("message 0 = {%02x, %q}", got[0].typ, got[0].payload) + } + if got[1].typ != MsgTerminalInput || string(got[1].payload) != "frame2" { + t.Errorf("message 1 = {%02x, %q}", got[1].typ, got[1].payload) + } +} + +// TestParserByteAtATime feeds one frame one byte at a time and expects exactly +// one callback with the correct type and payload. +func TestParserByteAtATime(t *testing.T) { + var got []collected + p := NewMessageParser(collect(&got)) + + frame := EncodeMessage(MsgResize, []byte(`{"cols":80,"rows":24}`)) + for _, b := range frame { + p.Feed([]byte{b}) + } + + if len(got) != 1 { + t.Fatalf("got %d messages, want 1", len(got)) + } + if got[0].typ != MsgResize { + t.Errorf("type = 0x%02x, want 0x%02x", got[0].typ, MsgResize) + } + want := []byte(`{"cols":80,"rows":24}`) + if !bytes.Equal(got[0].payload, want) { + t.Errorf("payload = %q, want %q", got[0].payload, want) + } +} + +// TestParserInterleavedTypes feeds frames of different types and verifies order. +func TestParserInterleavedTypes(t *testing.T) { + types := []byte{MsgStatusReq, MsgKillReq, MsgGetOutputReq, MsgStatusRes} + payloads := [][]byte{nil, nil, []byte(`{"lines":10}`), []byte(`{"alive":true,"pid":42}`)} + + var chunk []byte + for i, typ := range types { + chunk = append(chunk, EncodeMessage(typ, payloads[i])...) + } + + var got []collected + p := NewMessageParser(collect(&got)) + p.Feed(chunk) + + if len(got) != len(types) { + t.Fatalf("got %d messages, want %d", len(got), len(types)) + } + for i, g := range got { + if g.typ != types[i] { + t.Errorf("[%d] type = 0x%02x, want 0x%02x", i, g.typ, types[i]) + } + if !bytes.Equal(g.payload, payloads[i]) { + t.Errorf("[%d] payload = %q, want %q", i, g.payload, payloads[i]) + } + } +} + +// TestParserPayloadIsCopy verifies that mutating the fed slice after Feed does +// NOT corrupt the payload delivered to onMessage. +func TestParserPayloadIsCopy(t *testing.T) { + var got []collected + p := NewMessageParser(collect(&got)) + + src := []byte("original") + frame := EncodeMessage(MsgTerminalData, src) + p.Feed(frame) + + // Mutate the frame bytes that were fed. + for i := range frame { + frame[i] = 0xFF + } + + if len(got) != 1 { + t.Fatalf("got %d messages, want 1", len(got)) + } + if !bytes.Equal(got[0].payload, []byte("original")) { + t.Errorf("payload corrupted after mutating fed slice: got %q", got[0].payload) + } +} + +// TestParserZeroLengthFrame verifies a zero-payload frame (e.g. MsgStatusReq) parses. +func TestParserZeroLengthFrame(t *testing.T) { + var got []collected + p := NewMessageParser(collect(&got)) + p.Feed(EncodeMessage(MsgStatusReq, nil)) + + if len(got) != 1 { + t.Fatalf("got %d messages, want 1", len(got)) + } + if got[0].typ != MsgStatusReq { + t.Errorf("type = 0x%02x, want 0x%02x", got[0].typ, MsgStatusReq) + } + if len(got[0].payload) != 0 { + t.Errorf("payload len = %d, want 0", len(got[0].payload)) + } +} diff --git a/backend/internal/adapters/runtime/conpty/ring.go b/backend/internal/adapters/runtime/conpty/ring.go new file mode 100644 index 00000000..06d28c18 --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/ring.go @@ -0,0 +1,83 @@ +package conpty + +import ( + "strings" + "sync" +) + +// MaxOutputLines is the rolling line-buffer cap, matching MAX_OUTPUT_LINES in pty-host.ts. +const MaxOutputLines = 1000 + +// Ring is a bounded rolling buffer of terminal output lines, ANSI codes preserved. +// It mirrors the appendOutput state machine from pty-host.ts. +// Concurrent Append and Snapshot/Tail calls are safe. +type Ring struct { + mu sync.Mutex + lines []string // each entry is "line\n" (or bare text on FlushPartial) + partialLine string +} + +// NewRing returns an empty Ring. +func NewRing() *Ring { + return &Ring{} +} + +// Append mirrors appendOutput from pty-host.ts: prepend the current partialLine, +// split on newlines, store completed lines with "\n" re-appended, keep the last +// element as the new partialLine, then trim to MaxOutputLines. +func (r *Ring) Append(raw []byte) { + r.mu.Lock() + defer r.mu.Unlock() + + text := r.partialLine + string(raw) + parts := strings.Split(text, "\n") + // The last element is either "" (text ended with \n) or an incomplete line. + r.partialLine = parts[len(parts)-1] + for _, line := range parts[:len(parts)-1] { + r.lines = append(r.lines, line+"\n") + } + if len(r.lines) > MaxOutputLines { + // ponytail: slice off the head; ceiling: O(n) copy on every trim cycle. + // Upgrade path: circular buffer if trim rate is very high. + r.lines = r.lines[len(r.lines)-MaxOutputLines:] + } +} + +// FlushPartial pushes any in-progress partial line as a final entry. +// Called on PTY exit to mirror the pty-host.ts onExit handler. +func (r *Ring) FlushPartial() { + r.mu.Lock() + defer r.mu.Unlock() + + if r.partialLine == "" { + return + } + r.lines = append(r.lines, r.partialLine) + r.partialLine = "" +} + +// Snapshot returns all stored lines concatenated as raw bytes for scrollback replay. +// The in-progress partialLine is NOT included (matches TS outputBuffer.join("")). +func (r *Ring) Snapshot() []byte { + r.mu.Lock() + defer r.mu.Unlock() + + return []byte(strings.Join(r.lines, "")) +} + +// Tail returns the last n stored lines joined as a string. +// Mirrors the MSG_GET_OUTPUT_REQ handler: start = max(0, len-lines). +// n <= 0 returns "". +func (r *Ring) Tail(n int) string { + r.mu.Lock() + defer r.mu.Unlock() + + if n <= 0 { + return "" + } + start := len(r.lines) - n + if start < 0 { + start = 0 + } + return strings.Join(r.lines[start:], "") +} diff --git a/backend/internal/adapters/runtime/conpty/ring_test.go b/backend/internal/adapters/runtime/conpty/ring_test.go new file mode 100644 index 00000000..d954ee30 --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/ring_test.go @@ -0,0 +1,125 @@ +package conpty + +import ( + "strings" + "testing" +) + +// TestRingAppendPartialThenComplete verifies partial-line accumulation and +// that Snapshot/Tail reflect only completed lines. +func TestRingAppendPartialThenComplete(t *testing.T) { + r := NewRing() + r.Append([]byte("hel")) + r.Append([]byte("lo\nwor")) + + snap := string(r.Snapshot()) + if snap != "hello\n" { + t.Errorf("Snapshot = %q, want %q", snap, "hello\n") + } + + tail := r.Tail(10) + if tail != "hello\n" { + t.Errorf("Tail(10) = %q, want %q", tail, "hello\n") + } + + // Flush the partial "wor" + r.FlushPartial() + snap = string(r.Snapshot()) + if snap != "hello\nwor" { + t.Errorf("after FlushPartial Snapshot = %q, want %q", snap, "hello\nwor") + } +} + +// TestRingExceedsMaxOutputLines verifies the buffer trims to MaxOutputLines. +func TestRingExceedsMaxOutputLines(t *testing.T) { + r := NewRing() + // Push 1005 lines. + for i := 0; i < 1005; i++ { + r.Append([]byte("x\n")) + } + + snap := r.Snapshot() + got := strings.Count(string(snap), "\n") + if got != MaxOutputLines { + t.Errorf("stored %d lines, want %d", got, MaxOutputLines) + } +} + +// TestRingFlushPartialNoNewline verifies FlushPartial pushes a trailing line. +func TestRingFlushPartialNoNewline(t *testing.T) { + r := NewRing() + r.Append([]byte("line1\npartial")) + r.FlushPartial() + + snap := string(r.Snapshot()) + if !strings.Contains(snap, "partial") { + t.Errorf("Snapshot missing 'partial': %q", snap) + } + + // Calling FlushPartial again is a no-op. + r.FlushPartial() + snap2 := string(r.Snapshot()) + if snap2 != snap { + t.Errorf("second FlushPartial changed snapshot: %q -> %q", snap, snap2) + } +} + +// TestRingTailEdgeCases covers n > stored count and n <= 0. +func TestRingTailEdgeCases(t *testing.T) { + r := NewRing() + r.Append([]byte("a\nb\n")) + + if got := r.Tail(100); got != "a\nb\n" { + t.Errorf("Tail(100) = %q, want %q", got, "a\nb\n") + } + if got := r.Tail(0); got != "" { + t.Errorf("Tail(0) = %q, want empty", got) + } + if got := r.Tail(-1); got != "" { + t.Errorf("Tail(-1) = %q, want empty", got) + } +} + +// TestRingANSIRoundTrip verifies raw ANSI escape sequences survive storage intact. +func TestRingANSIRoundTrip(t *testing.T) { + ansi := "\x1b[31mhi\x1b[0m\n" + r := NewRing() + r.Append([]byte(ansi)) + + snap := string(r.Snapshot()) + if snap != ansi { + t.Errorf("Snapshot = %q, want %q", snap, ansi) + } + tail := r.Tail(1) + if tail != ansi { + t.Errorf("Tail(1) = %q, want %q", tail, ansi) + } +} + +// TestRingTailSubset verifies Tail returns exactly the last n lines. +func TestRingTailSubset(t *testing.T) { + r := NewRing() + for i := 0; i < 10; i++ { + r.Append([]byte("line\n")) + } + + tail3 := r.Tail(3) + if got := strings.Count(tail3, "\n"); got != 3 { + t.Errorf("Tail(3) contains %d newlines, want 3", got) + } +} + +// TestRingSnapshotExcludesPartial verifies the in-progress partial line is NOT +// included in Snapshot (matches TS semantics: only outputBuffer, not partialLine). +func TestRingSnapshotExcludesPartial(t *testing.T) { + r := NewRing() + r.Append([]byte("complete\npartial")) + + snap := string(r.Snapshot()) + if strings.Contains(snap, "partial") { + t.Errorf("Snapshot includes partial line: %q", snap) + } + if !strings.Contains(snap, "complete\n") { + t.Errorf("Snapshot missing complete line: %q", snap) + } +} From d67941b501636dbc736b8ef716151c8127000e39 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 23 Jun 2026 23:07:12 +0530 Subject: [PATCH 07/60] test(conpty): harden copy-safety and add concurrent ring test Strengthen TestParserPayloadIsCopy to catch internal-buffer aliasing: feed frame1, capture its payload, feed frame2 of the same length so the parser's buffer overwrites the frame1 region, then assert frame1's bytes are unchanged. The prior test only mutated the input slice post-Feed and did not exercise the real aliasing risk. Add TestRingConcurrent: 10 writer goroutines (Append) and 10 reader goroutines (Snapshot + Tail) running concurrently with a WaitGroup. The test is meaningful only under the race detector and catches any missing mu coverage on Ring's exported methods. Co-Authored-By: Claude Opus 4.8 --- .../adapters/runtime/conpty/proto_test.go | 35 +++++++++++------- .../adapters/runtime/conpty/ring_test.go | 36 +++++++++++++++++++ 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/backend/internal/adapters/runtime/conpty/proto_test.go b/backend/internal/adapters/runtime/conpty/proto_test.go index 83cc7487..1116ec5f 100644 --- a/backend/internal/adapters/runtime/conpty/proto_test.go +++ b/backend/internal/adapters/runtime/conpty/proto_test.go @@ -140,26 +140,35 @@ func TestParserInterleavedTypes(t *testing.T) { } } -// TestParserPayloadIsCopy verifies that mutating the fed slice after Feed does -// NOT corrupt the payload delivered to onMessage. +// TestParserPayloadIsCopy verifies that the payload delivered to onMessage is a +// true copy, not a subslice of the parser's internal buffer. It exercises the +// aliasing path that matters in practice: feed frame1, capture its payload, then +// feed frame2 of the SAME length so the parser reuses the same buffer region; +// frame1's captured bytes must be unchanged. This catches a regression where +// payload was a raw subslice of p.buf instead of a make+copy. func TestParserPayloadIsCopy(t *testing.T) { var got []collected p := NewMessageParser(collect(&got)) - src := []byte("original") - frame := EncodeMessage(MsgTerminalData, src) - p.Feed(frame) - - // Mutate the frame bytes that were fed. - for i := range frame { - frame[i] = 0xFF + // Feed frame1 and capture the delivered payload pointer. + frame1 := EncodeMessage(MsgTerminalData, []byte("original")) + p.Feed(frame1) + if len(got) != 1 { + t.Fatalf("after frame1: got %d messages, want 1", len(got)) } + captured := got[0].payload - if len(got) != 1 { - t.Fatalf("got %d messages, want 1", len(got)) + // Feed frame2 with the same payload length so the parser's internal buffer + // overwrites the exact byte range that frame1 occupied. + frame2 := EncodeMessage(MsgTerminalInput, []byte("XXXXXXXX")) // same len as "original" + p.Feed(frame2) + if len(got) != 2 { + t.Fatalf("after frame2: got %d messages, want 2", len(got)) } - if !bytes.Equal(got[0].payload, []byte("original")) { - t.Errorf("payload corrupted after mutating fed slice: got %q", got[0].payload) + + // frame1's captured payload must be unaffected by the subsequent Feed. + if !bytes.Equal(captured, []byte("original")) { + t.Errorf("frame1 payload aliased internal buffer: got %q after frame2", captured) } } diff --git a/backend/internal/adapters/runtime/conpty/ring_test.go b/backend/internal/adapters/runtime/conpty/ring_test.go index d954ee30..140131d3 100644 --- a/backend/internal/adapters/runtime/conpty/ring_test.go +++ b/backend/internal/adapters/runtime/conpty/ring_test.go @@ -2,6 +2,7 @@ package conpty import ( "strings" + "sync" "testing" ) @@ -123,3 +124,38 @@ func TestRingSnapshotExcludesPartial(t *testing.T) { t.Errorf("Snapshot missing complete line: %q", snap) } } + +// TestRingConcurrent validates the advertised goroutine-safety of Ring under the +// race detector. It spawns 10 writer goroutines (Append) and 10 reader goroutines +// (Snapshot + Tail) that all run concurrently; any data race will be caught by +// "go test -race". The test itself only asserts no panic and no race. +func TestRingConcurrent(t *testing.T) { + const goroutines = 10 + const iters = 100 + + r := NewRing() + var wg sync.WaitGroup + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iters; j++ { + r.Append([]byte("line\n")) + } + }() + } + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iters; j++ { + _ = r.Snapshot() + _ = r.Tail(10) + } + }() + } + + wg.Wait() +} From 6552e269bf15839db984a3c054dbbadf5908beb5 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 23 Jun 2026 23:11:52 +0530 Subject: [PATCH 08/60] feat(ptyregistry): port Windows pty-host sideband registry to Go Adds package ptyregistry under backend/internal/adapters/runtime/conpty/ptyregistry. Ports windows-pty-registry.ts: defensive read, atomic temp+rename write, delete-on-empty, register-replaces-same-ID, and auto-pruning List. PID liveness isolated behind build tags (syscall.Kill on Unix, OpenProcess on Windows). 10 tests all green on Darwin. Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/task-b2-report.md | 55 +++++ .../conpty/ptyregistry/pidalive_unix.go | 19 ++ .../conpty/ptyregistry/pidalive_windows.go | 19 ++ .../conpty/ptyregistry/ptyregistry_test.go | 229 ++++++++++++++++++ .../runtime/conpty/ptyregistry/registry.go | 164 +++++++++++++ 5 files changed, 486 insertions(+) create mode 100644 .superpowers/sdd/task-b2-report.md create mode 100644 backend/internal/adapters/runtime/conpty/ptyregistry/pidalive_unix.go create mode 100644 backend/internal/adapters/runtime/conpty/ptyregistry/pidalive_windows.go create mode 100644 backend/internal/adapters/runtime/conpty/ptyregistry/ptyregistry_test.go create mode 100644 backend/internal/adapters/runtime/conpty/ptyregistry/registry.go diff --git a/.superpowers/sdd/task-b2-report.md b/.superpowers/sdd/task-b2-report.md new file mode 100644 index 00000000..e15def0b --- /dev/null +++ b/.superpowers/sdd/task-b2-report.md @@ -0,0 +1,55 @@ +# Task B2 Report: Windows pty-host registry + +## Status: DONE + +## Files created (all under `backend/internal/adapters/runtime/conpty/ptyregistry/`) + +- `registry.go` - main package: `Entry` struct, `Register`, `Unregister`, `List`, `Clear`, `readRaw`, `writeRaw`, `registryFile`, `pidAlive` var. +- `pidalive_unix.go` (`//go:build !windows`) - `defaultPidAlive` via `syscall.Kill(pid, 0)`; nil and EPERM = alive, ESRCH = dead. +- `pidalive_windows.go` (`//go:build windows`) - `defaultPidAlive` via `windows.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, ...)`; success or ERROR_ACCESS_DENIED = alive. +- `ptyregistry_test.go` - 10 tests; all run on Darwin with HOME pointed at `t.TempDir()` and `pidAlive` overridden with a fake. + +## Build-tag split + +`pidalive_unix.go` covers Darwin, Linux, and every non-Windows target via `!windows`. `pidalive_windows.go` covers Windows only. The package var `var pidAlive = defaultPidAlive` in `registry.go` is the single injection point; tests replace it with a per-test closure. + +## Verification outputs (all from `backend/`) + +### `go build ./...` +``` +Go build: Success +``` + +### `GOOS=windows go build ./...` +``` +Go build: Success +``` + +### `GOOS=linux go build ./...` +``` +Go build: Success +``` + +### `go test ./internal/adapters/runtime/conpty/ptyregistry/...` +``` +Go test: 10 passed in 1 packages +``` +Tests covered: Register+List, register-replaces-same-ID, Unregister-removes, Unregister-no-op-when-absent, List-prunes-dead-PIDs (and rewrites file), empty-result-deletes-file, Clear-deletes-file, malformed-JSON-returns-empty, missing-file-returns-empty, atomic-write-produces-valid-JSON. + +### `go vet ./internal/adapters/runtime/conpty/ptyregistry/...` +``` +Go vet: No issues found +``` + +## Design fidelity notes + +- `readRaw` drops entries missing sessionId, ptyHostPid, or pipePath (mirrors TS filter). +- `writeRaw` uses temp-file + `os.Rename` (atomic on same filesystem); deletes the file when list is empty (mirrors TS `writeRaw`). +- `Register` is filter-then-append (same-SessionID replaced, not duplicated). +- `registeredAt` is caller-supplied; `time.Now()` is never called inside the registry. +- `registryFile()` is a function (not a const), so `t.Setenv("HOME", dir)` redirects all I/O. +- `golang.org/x/sys/windows` used only in `pidalive_windows.go`; no new go.mod dependency added. + +## Concerns + +None. All five verification commands green. diff --git a/backend/internal/adapters/runtime/conpty/ptyregistry/pidalive_unix.go b/backend/internal/adapters/runtime/conpty/ptyregistry/pidalive_unix.go new file mode 100644 index 00000000..e7e197b9 --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/ptyregistry/pidalive_unix.go @@ -0,0 +1,19 @@ +//go:build !windows + +package ptyregistry + +import ( + "errors" + "syscall" +) + +// defaultPidAlive probes PID liveness via signal 0. nil and EPERM both mean +// alive (process exists but may not be queryable). ESRCH means dead. +// Mirrors process.kill(pid, 0) with EPERM-means-alive from the TS source. +func defaultPidAlive(pid int) bool { + err := syscall.Kill(pid, 0) + if err == nil { + return true + } + return errors.Is(err, syscall.EPERM) +} diff --git a/backend/internal/adapters/runtime/conpty/ptyregistry/pidalive_windows.go b/backend/internal/adapters/runtime/conpty/ptyregistry/pidalive_windows.go new file mode 100644 index 00000000..1048ce5d --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/ptyregistry/pidalive_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +package ptyregistry + +import ( + "golang.org/x/sys/windows" +) + +// defaultPidAlive probes PID liveness via OpenProcess. SUCCESS means alive +// (CloseHandle and return true). ERROR_ACCESS_DENIED mirrors EPERM: the +// process exists but cannot be queried, so treat as alive. +func defaultPidAlive(pid int) bool { + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err == nil { + _ = windows.CloseHandle(h) + return true + } + return err == windows.ERROR_ACCESS_DENIED +} diff --git a/backend/internal/adapters/runtime/conpty/ptyregistry/ptyregistry_test.go b/backend/internal/adapters/runtime/conpty/ptyregistry/ptyregistry_test.go new file mode 100644 index 00000000..12b04143 --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/ptyregistry/ptyregistry_test.go @@ -0,0 +1,229 @@ +package ptyregistry + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// withFakePidAlive replaces the pidAlive var for the duration of the test. +func withFakePidAlive(t *testing.T, fn func(pid int) bool) { + t.Helper() + orig := pidAlive + pidAlive = fn + t.Cleanup(func() { pidAlive = orig }) +} + +// setupHome points HOME at a temp dir and returns the expected registry path. +func setupHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + return dir + "/.ao/windows-pty-hosts.json" +} + +func nowRFC3339() string { + return time.Now().UTC().Format(time.RFC3339) +} + +func TestRegisterThenList(t *testing.T) { + setupHome(t) + withFakePidAlive(t, func(int) bool { return true }) + + e := Entry{SessionID: "s1", PtyHostPID: 1234, PipePath: `\\.\pipe\ao-s1`, RegisteredAt: nowRFC3339()} + if err := Register(e); err != nil { + t.Fatal(err) + } + + got, err := List() + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].SessionID != "s1" { + t.Fatalf("expected [s1], got %v", got) + } +} + +func TestRegisterReplaceSameID(t *testing.T) { + setupHome(t) + withFakePidAlive(t, func(int) bool { return true }) + + e1 := Entry{SessionID: "s1", PtyHostPID: 111, PipePath: `\\.\pipe\ao-s1-a`, RegisteredAt: nowRFC3339()} + e2 := Entry{SessionID: "s1", PtyHostPID: 222, PipePath: `\\.\pipe\ao-s1-b`, RegisteredAt: nowRFC3339()} + if err := Register(e1); err != nil { + t.Fatal(err) + } + if err := Register(e2); err != nil { + t.Fatal(err) + } + + got, err := List() + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("expected 1 entry, got %d", len(got)) + } + if got[0].PtyHostPID != 222 { + t.Fatalf("expected PID 222, got %d", got[0].PtyHostPID) + } +} + +func TestUnregisterRemoves(t *testing.T) { + setupHome(t) + withFakePidAlive(t, func(int) bool { return true }) + + e := Entry{SessionID: "s1", PtyHostPID: 1234, PipePath: `\\.\pipe\ao-s1`, RegisteredAt: nowRFC3339()} + if err := Register(e); err != nil { + t.Fatal(err) + } + if err := Unregister("s1"); err != nil { + t.Fatal(err) + } + got, err := List() + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("expected empty, got %v", got) + } +} + +func TestUnregisterNoOpWhenAbsent(t *testing.T) { + setupHome(t) + withFakePidAlive(t, func(int) bool { return true }) + + if err := Unregister("nonexistent"); err != nil { + t.Fatal(err) + } +} + +func TestListPrunesDeadPIDs(t *testing.T) { + regPath := setupHome(t) + + // PID 1 alive, PID 2 dead. + alive := map[int]bool{1: true, 2: false} + withFakePidAlive(t, func(pid int) bool { return alive[pid] }) + + e1 := Entry{SessionID: "s1", PtyHostPID: 1, PipePath: `\\.\pipe\ao-s1`, RegisteredAt: nowRFC3339()} + e2 := Entry{SessionID: "s2", PtyHostPID: 2, PipePath: `\\.\pipe\ao-s2`, RegisteredAt: nowRFC3339()} + if err := Register(e1); err != nil { + t.Fatal(err) + } + if err := Register(e2); err != nil { + t.Fatal(err) + } + + got, err := List() + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].SessionID != "s1" { + t.Fatalf("expected [s1], got %v", got) + } + + // Verify the on-disk file was rewritten with only the live entry. + data, err := os.ReadFile(regPath) + if err != nil { + t.Fatal(err) + } + var disk []Entry + if err := json.Unmarshal(data, &disk); err != nil { + t.Fatal(err) + } + if len(disk) != 1 || disk[0].SessionID != "s1" { + t.Fatalf("disk should have only s1, got %v", disk) + } +} + +func TestEmptyResultDeletesFile(t *testing.T) { + regPath := setupHome(t) + withFakePidAlive(t, func(int) bool { return true }) + + e := Entry{SessionID: "s1", PtyHostPID: 1, PipePath: `\\.\pipe\ao-s1`, RegisteredAt: nowRFC3339()} + if err := Register(e); err != nil { + t.Fatal(err) + } + // Unregister last entry -> file should be deleted. + if err := Unregister("s1"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(regPath); !os.IsNotExist(err) { + t.Fatal("expected registry file to be deleted") + } +} + +func TestClearDeletesFile(t *testing.T) { + regPath := setupHome(t) + withFakePidAlive(t, func(int) bool { return true }) + + e := Entry{SessionID: "s1", PtyHostPID: 1, PipePath: `\\.\pipe\ao-s1`, RegisteredAt: nowRFC3339()} + if err := Register(e); err != nil { + t.Fatal(err) + } + if err := Clear(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(regPath); !os.IsNotExist(err) { + t.Fatal("expected registry file to be deleted after Clear") + } +} + +func TestMalformedJSONReturnsEmpty(t *testing.T) { + setupHome(t) + withFakePidAlive(t, func(int) bool { return true }) + + // Write malformed JSON directly. + path, _ := registryFile() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("not json {{{"), 0o600); err != nil { + t.Fatal(err) + } + + got, err := List() + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("expected empty on malformed JSON, got %v", got) + } +} + +func TestMissingFileReturnsEmpty(t *testing.T) { + setupHome(t) + withFakePidAlive(t, func(int) bool { return true }) + + got, err := List() + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("expected empty for missing file, got %v", got) + } +} + +func TestAtomicWriteProducesValidJSON(t *testing.T) { + regPath := setupHome(t) + withFakePidAlive(t, func(int) bool { return true }) + + e := Entry{SessionID: "s1", PtyHostPID: 99, PipePath: `\\.\pipe\ao-s1`, RegisteredAt: nowRFC3339()} + if err := Register(e); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(regPath) + if err != nil { + t.Fatal(err) + } + var entries []Entry + if err := json.Unmarshal(data, &entries); err != nil { + t.Fatalf("registry file is not valid JSON: %v", err) + } + if len(entries) != 1 || entries[0].PtyHostPID != 99 { + t.Fatalf("unexpected entries: %v", entries) + } +} diff --git a/backend/internal/adapters/runtime/conpty/ptyregistry/registry.go b/backend/internal/adapters/runtime/conpty/ptyregistry/registry.go new file mode 100644 index 00000000..9f8fe614 --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/ptyregistry/registry.go @@ -0,0 +1,164 @@ +// Package ptyregistry is a sideband JSON list of live Windows pty-host +// processes so ao stop can find and graceful-kill them even when session +// metadata is lost. Ported from agent-orchestrator's windows-pty-registry.ts. +package ptyregistry + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// Entry is one registered pty-host process. +type Entry struct { + SessionID string `json:"sessionId"` + PtyHostPID int `json:"ptyHostPid"` + PipePath string `json:"pipePath"` + RegisteredAt string `json:"registeredAt"` // RFC3339; set by caller +} + +// pidAlive is the PID-liveness probe. Tests replace it with a fake. +// defaultPidAlive is provided in build-tagged files (pidalive_unix.go / +// pidalive_windows.go). +var pidAlive = defaultPidAlive + +// registryFile resolves ~/.ao/windows-pty-hosts.json. Uses os.UserHomeDir() +// so t.Setenv("HOME", dir) in tests redirects reads/writes to a temp dir. +// ponytail: HOME-based resolution; no AO_DATA_DIR override needed here. +func registryFile() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".ao", "windows-pty-hosts.json"), nil +} + +// readRaw reads and defensively parses the registry. Missing file or malformed +// JSON both return an empty slice (mirrors readRaw in the TS source). +func readRaw() []Entry { + path, err := registryFile() + if err != nil { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + // Missing file is fine. + return nil + } + var parsed []json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + return nil + } + out := make([]Entry, 0, len(parsed)) + for _, raw := range parsed { + var e Entry + if err := json.Unmarshal(raw, &e); err != nil { + continue + } + // Drop entries missing required fields (mirrors TS filter). + if e.SessionID == "" || e.PtyHostPID == 0 || e.PipePath == "" { + continue + } + out = append(out, e) + } + return out +} + +// writeRaw atomically writes entries to the registry file. When entries is +// empty it deletes the file instead (mirrors writeRaw in the TS source). +func writeRaw(entries []Entry) error { + path, err := registryFile() + if err != nil { + return err + } + + if len(entries) == 0 { + err := os.Remove(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + + data, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return err + } + + // Atomic write: temp file in same dir then rename (same filesystem). + tmp, err := os.CreateTemp(dir, "pty-hosts-*.json.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { + // Best-effort cleanup of temp file on failure. + _ = os.Remove(tmpName) + }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// Register adds or replaces the entry for entry.SessionID. registeredAt must +// be set by the caller (e.g. time.Now().UTC().Format(time.RFC3339)). +func Register(entry Entry) error { + next := make([]Entry, 0) + for _, e := range readRaw() { + if e.SessionID != entry.SessionID { + next = append(next, e) + } + } + next = append(next, entry) + return writeRaw(next) +} + +// Unregister removes the entry for sessionID. No-op if absent. +func Unregister(sessionID string) error { + all := readRaw() + next := make([]Entry, 0, len(all)) + for _, e := range all { + if e.SessionID != sessionID { + next = append(next, e) + } + } + if len(next) == len(all) { + return nil // absent, no-op + } + return writeRaw(next) +} + +// List returns all entries whose PtyHostPID is still alive, auto-pruning dead +// ones. The file is rewritten if any entries were pruned. +func List() ([]Entry, error) { + all := readRaw() + live := make([]Entry, 0, len(all)) + for _, e := range all { + if pidAlive(e.PtyHostPID) { + live = append(live, e) + } + } + if len(live) != len(all) { + if err := writeRaw(live); err != nil { + return live, err + } + } + return live, nil +} + +// Clear deletes the registry file. Best-effort; used by tests and recovery. +func Clear() error { + return writeRaw(nil) +} From 41ce4170de26461c3289914b8c6f3fe2dd6d9f2e Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 23 Jun 2026 23:14:25 +0530 Subject: [PATCH 09/60] chore(sdd): phase B briefs and progress for B1-B3 Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/final-review-package.txt | 2380 +++++++++++++++++++ .superpowers/sdd/progress.md | 30 +- .superpowers/sdd/task-b1-brief.md | 119 + .superpowers/sdd/task-b1-report.md | 126 + .superpowers/sdd/task-b1-review-package.txt | 912 +++++++ .superpowers/sdd/task-b2-brief.md | 102 + .superpowers/sdd/task-b3-brief.md | 137 ++ 7 files changed, 3805 insertions(+), 1 deletion(-) create mode 100644 .superpowers/sdd/final-review-package.txt create mode 100644 .superpowers/sdd/task-b1-brief.md create mode 100644 .superpowers/sdd/task-b1-report.md create mode 100644 .superpowers/sdd/task-b1-review-package.txt create mode 100644 .superpowers/sdd/task-b2-brief.md create mode 100644 .superpowers/sdd/task-b3-brief.md diff --git a/.superpowers/sdd/final-review-package.txt b/.superpowers/sdd/final-review-package.txt new file mode 100644 index 00000000..f015c844 --- /dev/null +++ b/.superpowers/sdd/final-review-package.txt @@ -0,0 +1,2380 @@ +=== COMMITS === +4b21e4b chore: tidy runtime-neutral comments and doctor import grouping +b459abf feat(runtime): wire tmux on Darwin/Linux via runtimeselect, keep zell... +44c3e61 fix(tmux): address four code-review findings in tmux runtime adapter +bc23964 feat(runtime): add tmux adapter package + +=== STAT (excluding sdd scratch) === +.../runtime/runtimeselect/runtimeselect.go | 46 ++ + backend/internal/adapters/runtime/tmux/commands.go | 64 +++ + backend/internal/adapters/runtime/tmux/tmux.go | 482 ++++++++++++++++ + .../adapters/runtime/tmux/tmux_integration_test.go | 143 +++++ + .../internal/adapters/runtime/tmux/tmux_test.go | 616 +++++++++++++++++++++ + backend/internal/cli/doctor.go | 30 +- + backend/internal/cli/doctor_test.go | 38 +- + backend/internal/cli/spawn.go | 21 +- + backend/internal/daemon/daemon.go | 24 +- + backend/internal/daemon/lifecycle_wiring.go | 6 +- + backend/internal/daemon/wiring_test.go | 7 +- + 11 files changed, 1427 insertions(+), 50 deletions(-) + +=== DIFF (backend only) === +.../runtime/runtimeselect/runtimeselect.go | 46 ++ + backend/internal/adapters/runtime/tmux/commands.go | 64 +++ + backend/internal/adapters/runtime/tmux/tmux.go | 482 ++++++++++++++++ + .../adapters/runtime/tmux/tmux_integration_test.go | 143 +++++ + .../internal/adapters/runtime/tmux/tmux_test.go | 616 +++++++++++++++++++++ + backend/internal/cli/doctor.go | 30 +- + backend/internal/cli/doctor_test.go | 38 +- + backend/internal/cli/spawn.go | 21 +- + backend/internal/daemon/daemon.go | 24 +- + backend/internal/daemon/lifecycle_wiring.go | 6 +- + backend/internal/daemon/wiring_test.go | 7 +- + 11 files changed, 1427 insertions(+), 50 deletions(-) + +diff --git a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go +new file mode 100644 +index 0000000..e6ca4a0 +--- /dev/null ++++ b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go +@@ -0,0 +1,46 @@ ++// Package runtimeselect picks the correct runtime backend by platform: ++// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). ++package runtimeselect ++ ++import ( ++ "context" ++ "log/slog" ++ "os" ++ "runtime" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" ++) ++ ++// Runtime is the union interface that both tmux and zellij satisfy. ++// It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods ++// the daemon wires directly. ++type Runtime interface { ++ ports.Runtime // Create, Destroy, IsAlive ++ SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error ++ GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) ++ AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) ++} ++ ++// Compile-time assertions: both adapters must implement the union interface. ++var _ Runtime = (*tmux.Runtime)(nil) ++var _ Runtime = (*zellij.Runtime)(nil) ++ ++// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. ++// log is used only for the Windows zellij socket-dir warning; nil is safe. ++func New(log *slog.Logger) Runtime { ++ if runtime.GOOS != "windows" { ++ return tmux.New(tmux.Options{}) ++ } ++ // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. ++ dir := zellij.DefaultSocketDir() ++ if dir != "" { ++ if err := os.MkdirAll(dir, 0o700); err != nil { ++ if log != nil { ++ log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) ++ } ++ } ++ } ++ return zellij.New(zellij.Options{SocketDir: dir}) ++} +diff --git a/backend/internal/adapters/runtime/tmux/commands.go b/backend/internal/adapters/runtime/tmux/commands.go +new file mode 100644 +index 0000000..1c2cf30 +--- /dev/null ++++ b/backend/internal/adapters/runtime/tmux/commands.go +@@ -0,0 +1,64 @@ ++package tmux ++ ++import "fmt" ++ ++// newSessionArgs builds args for `tmux new-session -d -s -x 220 -y 50 ++// -c -c `. The shell -c form runs the launch command ++// inside the configured shell so exported env vars and quoting work correctly. ++func newSessionArgs(id, cwd, shellPath, launchCmd string) []string { ++ return []string{ ++ "new-session", "-d", ++ "-s", id, ++ "-x", "220", ++ "-y", "50", ++ "-c", cwd, ++ shellPath, "-c", launchCmd, ++ } ++} ++ ++// setStatusOffArgs hides the tmux status bar for the given session. ++// set-option uses pane-targeting syntax which does not accept the `=` prefix, ++// so we pass the session name directly. ++func setStatusOffArgs(id string) []string { ++ return []string{"set-option", "-t", id, "status", "off"} ++} ++ ++// killSessionArgs builds args for `tmux kill-session -t =`. The `=` prefix ++// requests exact-name matching so a session "foo" does not accidentally match ++// "foobar" (tmux otherwise does unique-prefix matching). ++func killSessionArgs(id string) []string { ++ return []string{"kill-session", "-t", exactSessionTarget(id)} ++} ++ ++// hasSessionArgs builds args for `tmux has-session -t =`. The `=` prefix ++// requests exact-name matching (see killSessionArgs). ++func hasSessionArgs(id string) []string { ++ return []string{"has-session", "-t", exactSessionTarget(id)} ++} ++ ++// exactSessionTarget wraps id in tmux's exact-match prefix `=` so session- ++// selection commands (-t) target only the session with that precise name. ++// Only kill-session and has-session support this prefix; pane-targeting ++// commands (send-keys, capture-pane, set-option) use a plain session name. ++func exactSessionTarget(id string) string { ++ return "=" + id ++} ++ ++// sendKeysLiteralArgs builds args for `tmux send-keys -t -l `. ++// The -l flag stops tmux interpreting words like "Enter" as key names so the ++// text is sent verbatim. ++func sendKeysLiteralArgs(id, chunk string) []string { ++ return []string{"send-keys", "-t", id, "-l", chunk} ++} ++ ++// sendEnterArgs builds args for `tmux send-keys -t Enter` to submit the ++// queued input. ++func sendEnterArgs(id string) []string { ++ return []string{"send-keys", "-t", id, "Enter"} ++} ++ ++// capturePaneArgs builds args for `tmux capture-pane -t -p -S -`. ++// -p prints to stdout; -S - starts n lines back in history. ++func capturePaneArgs(id string, lines int) []string { ++ return []string{"capture-pane", "-t", id, "-p", "-S", fmt.Sprintf("-%d", lines)} ++} +diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go +new file mode 100644 +index 0000000..be6e2fd +--- /dev/null ++++ b/backend/internal/adapters/runtime/tmux/tmux.go +@@ -0,0 +1,482 @@ ++// Package tmux implements ports.Runtime using tmux sessions on Darwin/Linux. ++package tmux ++ ++import ( ++ "context" ++ "crypto/sha256" ++ "encoding/hex" ++ "errors" ++ "fmt" ++ "os" ++ "os/exec" ++ "regexp" ++ "sort" ++ "strings" ++ "time" ++ "unicode/utf8" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/domain" ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" ++) ++ ++const ( ++ defaultTimeout = 5 * time.Second ++ defaultChunkBytes = 16 * 1024 ++) ++ ++var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) ++ ++var getenv = os.Getenv ++ ++// Options configures a tmux Runtime. Every field has a sensible default (see ++// New), so the zero value is usable. ++type Options struct { ++ Binary string // default "tmux" (resolved via exec.LookPath) ++ Shell string // default $SHELL else /bin/sh ++ Timeout time.Duration // default 5s ++ ChunkSize int // default 16*1024 ++} ++ ++// Runtime runs agent sessions inside tmux sessions, driving them via the tmux ++// CLI. It implements ports.Runtime. ++type Runtime struct { ++ binary string ++ shell string ++ timeout time.Duration ++ chunkSize int ++ runner runner ++} ++ ++var _ ports.Runtime = (*Runtime)(nil) ++ ++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 = append(append([]string(nil), os.Environ()...), env...) ++ return cmd.CombinedOutput() ++} ++ ++func (execRunner) Start(env []string, name string, args ...string) error { ++ cmd := exec.Command(name, args...) ++ cmd.Env = append(append([]string(nil), os.Environ()...), env...) ++ return cmd.Start() ++} ++ ++// New builds a tmux Runtime, filling unset Options with defaults: binary "tmux" ++// (resolved via exec.LookPath), shell from $SHELL (else /bin/sh), and the ++// default timeout and output chunk size. ++func New(opts Options) *Runtime { ++ binary := opts.Binary ++ if binary == "" { ++ if path, err := exec.LookPath("tmux"); err == nil { ++ binary = path ++ } else { ++ binary = "tmux" ++ } ++ } ++ timeout := opts.Timeout ++ if timeout == 0 { ++ timeout = defaultTimeout ++ } ++ shellPath := opts.Shell ++ if shellPath == "" { ++ shellPath = getenv("SHELL") ++ } ++ if shellPath == "" { ++ shellPath = "/bin/sh" ++ } ++ chunkSize := opts.ChunkSize ++ if chunkSize <= 0 { ++ chunkSize = defaultChunkBytes ++ } ++ return &Runtime{ ++ binary: binary, ++ shell: shellPath, ++ timeout: timeout, ++ chunkSize: chunkSize, ++ runner: execRunner{}, ++ } ++} ++ ++// Create starts a new tmux session in the workspace, running the agent's ++// launch command with a keep-alive shell, and returns a handle to it. ++func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { ++ id, err := tmuxSessionName(cfg.SessionID) ++ if err != nil { ++ return ports.RuntimeHandle{}, err ++ } ++ if cfg.WorkspacePath == "" { ++ return ports.RuntimeHandle{}, errors.New("tmux runtime: workspace path is required") ++ } ++ if len(cfg.Argv) == 0 { ++ return ports.RuntimeHandle{}, errors.New("tmux runtime: launch command is required") ++ } ++ if err := validateEnvKeys(cfg.Env); err != nil { ++ return ports.RuntimeHandle{}, err ++ } ++ ++ launchCmd := buildLaunchCommand(cfg, r.shell) ++ args := newSessionArgs(id, cfg.WorkspacePath, r.shell, launchCmd) ++ if _, err := r.run(ctx, args...); err != nil { ++ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: create session %s: %w", id, err) ++ } ++ ++ // Hide the status bar in the embedded terminal: it clutters the view and ++ // was not designed for the in-browser display context. ++ if _, err := r.run(ctx, setStatusOffArgs(id)...); err != nil { ++ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) ++ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: set status %s: %w", id, err) ++ } ++ ++ handle := ports.RuntimeHandle{ID: id} ++ alive, err := r.IsAlive(ctx, handle) ++ if err != nil { ++ _ = r.Destroy(context.Background(), handle) ++ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: verify session %s: %w", id, err) ++ } ++ if !alive { ++ _ = r.Destroy(context.Background(), handle) ++ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: session %s exited before ready", id) ++ } ++ return handle, nil ++} ++ ++// Destroy kills the handle's tmux session. An already-gone session is treated ++// as success (idempotent). ++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, killSessionArgs(id)...) ++ if err != nil { ++ var exitErr *exec.ExitError ++ if errors.As(err, &exitErr) && killSessionMissingOutput(string(out)) { ++ return nil ++ } ++ return fmt.Errorf("tmux runtime: destroy session %s: %w", id, err) ++ } ++ return nil ++} ++ ++// IsAlive reports whether the handle's session still exists via `tmux ++// has-session`. Exit 0 means alive. A non-zero exit with output indicating the ++// session or server is missing is a definitive false, nil. Any other non-zero ++// exit is a probe error (not proof of death) so callers (the reaper feeding ++// the LCM) treat it as a failed probe and never kill a session on a transient ++// error. ++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, hasSessionArgs(id)...) ++ if err != nil { ++ var exitErr *exec.ExitError ++ if errors.As(err, &exitErr) && sessionMissingOutput(string(out)) { ++ return false, nil ++ } ++ return false, fmt.Errorf("tmux runtime: probe session %s: %w", id, err) ++ } ++ return true, nil ++} ++ ++// SendMessage sends literal text to the session (chunked via send-keys -l) then ++// presses Enter to submit. ++// ++// ponytail: send-keys -l chunked is simpler than load-buffer/paste-buffer; the ++// ceiling is very large messages may be slower, but chunk size defaults to 16 KB ++// which is ample for agent prompts. ++func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { ++ id, err := handleID(handle) ++ if err != nil { ++ return err ++ } ++ for _, chunk := range chunks(message, r.chunkSize) { ++ if _, err := r.run(ctx, sendKeysLiteralArgs(id, chunk)...); err != nil { ++ return fmt.Errorf("tmux runtime: send message %s: %w", id, err) ++ } ++ } ++ if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil { ++ return fmt.Errorf("tmux runtime: send enter %s: %w", id, err) ++ } ++ return nil ++} ++ ++// GetOutput returns the last `lines` lines of the session pane's captured ++// output. ++func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { ++ id, err := handleID(handle) ++ if err != nil { ++ return "", err ++ } ++ if lines <= 0 { ++ return "", errors.New("tmux runtime: lines must be positive") ++ } ++ out, err := r.run(ctx, capturePaneArgs(id, lines)...) ++ if err != nil { ++ return "", fmt.Errorf("tmux runtime: capture output %s: %w", id, err) ++ } ++ return tailLines(trimTrailingBlankLines(string(out)), lines), nil ++} ++ ++// AttachCommand returns the argv a human runs to attach their terminal to the ++// session. No per-session env block is needed for tmux (unlike zellij's Windows ++// ConPTY path), so env is always nil. ++func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { ++ id, err := handleID(handle) ++ if err != nil { ++ return nil, nil, err ++ } ++ return []string{r.binary, "attach-session", "-t", id}, nil, nil ++} ++ ++// run wraps runner.Run with a per-call timeout context. ++func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { ++ cmdCtx, cancel := context.WithTimeout(ctx, r.timeout) ++ defer cancel() ++ out, err := r.runner.Run(cmdCtx, nil, r.binary, args...) ++ if cmdCtx.Err() != nil { ++ return out, cmdCtx.Err() ++ } ++ if err != nil { ++ return out, commandError{err: err, output: strings.TrimSpace(string(out))} ++ } ++ return out, nil ++} ++ ++// -- session name helpers -- ++ ++func tmuxSessionName(id domain.SessionID) (string, error) { ++ raw := string(id) ++ if raw == "" { ++ return "", errors.New("tmux runtime: session id is required") ++ } ++ return SessionName(raw), nil ++} ++ ++// SessionName returns the tmux session name the runtime registers for a given ++// session id, applying the same sanitisation Create does. Callers that print an ++// attach hint must use this rather than the raw id. ++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 handleID(handle ports.RuntimeHandle) (string, error) { ++ id := handle.ID ++ if id == "" { ++ return "", errors.New("tmux runtime: session id is required") ++ } ++ if !sessionIDPattern.MatchString(id) { ++ return "", fmt.Errorf("tmux runtime: invalid handle id %q", id) ++ } ++ return id, nil ++} ++ ++// -- output detection helpers -- ++ ++// sessionMissingOutput reports whether a non-zero `tmux has-session` or ++// `tmux kill-session` exit is definitively "session does not exist" rather ++// than a transient probe failure. ++func sessionMissingOutput(out string) bool { ++ s := strings.ToLower(out) ++ return strings.Contains(s, "can't find session") || ++ strings.Contains(s, "no server running") || ++ strings.Contains(s, "error connecting") || ++ strings.Contains(s, "session not found") ++} ++ ++// killSessionMissingOutput reports whether a non-zero `tmux kill-session` ++// failed because the session was already gone. ++func killSessionMissingOutput(out string) bool { ++ return sessionMissingOutput(out) ++} ++ ++// -- text helpers (ported from zellij) -- ++ ++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, "") ++} ++ ++// -- env / quoting helpers -- ++ ++func validateEnvKeys(env map[string]string) error { ++ for key := range env { ++ if !validEnvKey(key) { ++ return fmt.Errorf("tmux runtime: invalid env key %q", key) ++ } ++ } ++ return nil ++} ++ ++func validEnvKey(key string) bool { ++ if key == "" { ++ return false ++ } ++ for i, r := range key { ++ if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { ++ continue ++ } ++ if i > 0 && r >= '0' && r <= '9' { ++ continue ++ } ++ return false ++ } ++ return true ++} ++ ++func sortedKeys(m map[string]string) []string { ++ keys := make([]string, 0, len(m)) ++ for k := range m { ++ keys = append(keys, k) ++ } ++ sort.Strings(keys) ++ return keys ++} ++ ++func shellQuote(s string) string { ++ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" ++} ++ ++// buildLaunchCommand builds the shell command string passed to `sh -c` (or the ++// configured shell). It exports env vars, then runs argv, then execs a ++// keep-alive interactive shell so the tmux session survives the agent exiting. ++// ++// ponytail: PATH handling matches the zellij unix path: PATH from cfg.Env is ++// exported last, after all other keys, so an explicit override takes effect. ++func buildLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { ++ path := cfg.Env["PATH"] ++ if path == "" { ++ path = getenv("PATH") ++ } ++ ++ var b strings.Builder ++ for _, key := range sortedKeys(cfg.Env) { ++ if key == "PATH" { ++ continue ++ } ++ b.WriteString("export ") ++ b.WriteString(key) ++ b.WriteString("=") ++ b.WriteString(shellQuote(cfg.Env[key])) ++ b.WriteString("; ") ++ } ++ if path != "" { ++ b.WriteString("export PATH=") ++ b.WriteString(shellQuote(path)) ++ b.WriteString("; ") ++ } ++ // Quote each argv word so spaces inside a word are preserved. ++ parts := make([]string, len(cfg.Argv)) ++ for i, a := range cfg.Argv { ++ parts[i] = shellQuote(a) ++ } ++ b.WriteString(strings.Join(parts, " ")) ++ // Keep the tmux session alive after the agent exits so the operator can ++ // inspect the terminal. The shell variable expansion picks up $SHELL from ++ // the process env if set, otherwise falls back to /bin/sh. ++ b.WriteString(`; exec "${SHELL:-/bin/sh}" -i`) ++ return b.String() ++} ++ ++// -- error type -- ++ ++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 } +diff --git a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go +new file mode 100644 +index 0000000..1f491f8 +--- /dev/null ++++ b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go +@@ -0,0 +1,143 @@ ++package tmux ++ ++import ( ++ "context" ++ "os/exec" ++ "strings" ++ "testing" ++ "time" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/domain" ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" ++) ++ ++func TestRuntimeIntegration(t *testing.T) { ++ if _, err := exec.LookPath("tmux"); err != nil { ++ t.Skip("tmux unavailable") ++ } ++ ++ ctx := context.Background() ++ id := strings.ReplaceAll(t.Name(), "/", "_") ++ r := New(Options{Timeout: 5 * time.Second}) ++ ++ // Ensure clean slate: ignore errors (session may not exist). ++ _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) ++ ++ t.Cleanup(func() { ++ // Always destroy so a test failure never leaks a tmux session. ++ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) ++ }) ++ ++ h, err := r.Create(ctx, ports.RuntimeConfig{ ++ SessionID: domain.SessionID(id), ++ WorkspacePath: t.TempDir(), ++ // Run a trivial command then drop into an interactive shell (the keep-alive ++ // exec is added by buildLaunchCommand, but we also verify here that output ++ // appears). ++ Argv: []string{"sh", "-c", "echo hello-from-tmux"}, ++ Env: map[string]string{"AO_SESSION_ID": id}, ++ }) ++ if err != nil { ++ t.Fatalf("Create: %v", err) ++ } ++ ++ alive, err := r.IsAlive(ctx, h) ++ if err != nil { ++ t.Fatalf("IsAlive: %v", err) ++ } ++ if !alive { ++ t.Fatal("alive = false, want true after create") ++ } ++ ++ // Wait for the echo output to appear (the session may take a moment to ++ // write it to the pane history). ++ out := waitForOutput(t, r, h, "hello-from-tmux", 5*time.Second) ++ if !strings.Contains(out, "hello-from-tmux") { ++ t.Fatalf("output = %q, want hello-from-tmux", out) ++ } ++ ++ // Send a command and verify it echoes back. ++ if err := r.SendMessage(ctx, h, "echo hello-send"); err != nil { ++ t.Fatalf("SendMessage: %v", err) ++ } ++ out = waitForOutput(t, r, h, "hello-send", 5*time.Second) ++ if !strings.Contains(out, "hello-send") { ++ t.Fatalf("output after SendMessage = %q, want hello-send", out) ++ } ++ ++ // Destroy and verify liveness goes false. ++ if err := r.Destroy(ctx, h); err != nil { ++ t.Fatalf("Destroy: %v", err) ++ } ++ alive, err = r.IsAlive(ctx, h) ++ if err != nil { ++ t.Fatalf("IsAlive after destroy: %v", err) ++ } ++ if alive { ++ t.Fatal("alive after destroy = true, want false") ++ } ++} ++ ++// TestRuntimeIntegrationExactSessionParsing verifies that IsAlive uses exact ++// session matching and does not treat a prefix as a live session. ++func TestRuntimeIntegrationExactSessionParsing(t *testing.T) { ++ if _, err := exec.LookPath("tmux"); err != nil { ++ t.Skip("tmux unavailable") ++ } ++ ++ ctx := context.Background() ++ base := strings.ReplaceAll(t.Name(), "/", "_") ++ longID := base + "_long" ++ prefixID := base ++ ++ r := New(Options{Timeout: 5 * time.Second}) ++ _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) ++ _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID}) ++ ++ t.Cleanup(func() { ++ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: longID}) ++ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: prefixID}) ++ }) ++ ++ h, err := r.Create(ctx, ports.RuntimeConfig{ ++ SessionID: domain.SessionID(longID), ++ WorkspacePath: t.TempDir(), ++ Argv: []string{"sh", "-c", "echo ready"}, ++ }) ++ if err != nil { ++ t.Fatalf("Create: %v", err) ++ } ++ ++ // tmux has-session -t should NOT match because tmux ++ // requires the exact session name when using -t with a plain string (not a ++ // glob). Verify by probing the prefix handle directly. ++ prefixAlive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID}) ++ if err != nil { ++ // tmux may return an error (session not found) rather than exit 0. ++ // That is acceptable here: the point is the prefix must not be alive. ++ t.Logf("IsAlive prefix returned error (acceptable): %v", err) ++ } ++ if prefixAlive { ++ _ = r.Destroy(ctx, h) ++ t.Fatal("prefix handle reported alive; tmux session matching is not exact") ++ } ++} ++ ++// waitForOutput polls GetOutput until out contains want or the deadline passes. ++func waitForOutput(t *testing.T, r *Runtime, h ports.RuntimeHandle, want string, deadline time.Duration) string { ++ t.Helper() ++ end := time.Now().Add(deadline) ++ var out string ++ for time.Now().Before(end) { ++ var err error ++ out, err = r.GetOutput(context.Background(), h, 50) ++ if err != nil { ++ t.Fatalf("GetOutput: %v", err) ++ } ++ if strings.Contains(out, want) { ++ return out ++ } ++ time.Sleep(100 * time.Millisecond) ++ } ++ return out ++} +diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go +new file mode 100644 +index 0000000..72b165b +--- /dev/null ++++ b/backend/internal/adapters/runtime/tmux/tmux_test.go +@@ -0,0 +1,616 @@ ++package tmux ++ ++import ( ++ "context" ++ "errors" ++ "os/exec" ++ "reflect" ++ "strings" ++ "testing" ++ "time" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/domain" ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" ++) ++ ++// -- fakeRunner test seam (mirrors zellij_test.go exactly) -- ++ ++type fakeRunner struct { ++ calls []runnerCall ++ outputs [][]byte ++ err error ++} ++ ++type runnerCall struct { ++ env []string ++ name string ++ args []string ++} ++ ++func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { ++ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) ++ var out []byte ++ if len(f.outputs) > 0 { ++ out = f.outputs[0] ++ f.outputs = f.outputs[1:] ++ } ++ if f.err != nil { ++ return out, f.err ++ } ++ return out, nil ++} ++ ++func (f *fakeRunner) Start(env []string, name string, args ...string) error { ++ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) ++ if len(f.outputs) > 0 { ++ f.outputs = f.outputs[1:] ++ } ++ return f.err ++} ++ ++// -- helpers -- ++ ++func newTestRuntime(chunkSize int) (*Runtime, *fakeRunner) { ++ fr := &fakeRunner{} ++ r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/sh", ChunkSize: chunkSize}) ++ r.runner = fr ++ return r, fr ++} ++ ++// -- Options / New tests -- ++ ++func TestNewDefaultsToPortableShell(t *testing.T) { ++ t.Setenv("SHELL", "") ++ r := New(Options{}) ++ if got := r.shell; got != "/bin/sh" { ++ t.Fatalf("default shell = %q, want /bin/sh", got) ++ } ++} ++ ++func TestNewPicksUpShellFromEnv(t *testing.T) { ++ t.Setenv("SHELL", "/bin/zsh") ++ r := New(Options{}) ++ if got := r.shell; got != "/bin/zsh" { ++ t.Fatalf("shell = %q, want /bin/zsh", got) ++ } ++} ++ ++// -- command builder tests -- ++ ++func TestCommandBuilders(t *testing.T) { ++ if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/sh", `echo hi; exec "${SHELL:-/bin/sh}" -i`), ++ []string{"new-session", "-d", "-s", "sess-1", "-x", "220", "-y", "50", "-c", "/tmp/ws", "/bin/sh", "-c", `echo hi; exec "${SHELL:-/bin/sh}" -i`}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("newSessionArgs = %#v, want %#v", got, want) ++ } ++ // set-option uses pane-targeting (no = prefix). ++ if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "sess-1", "status", "off"}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want) ++ } ++ // kill-session and has-session use exact-match prefix =. ++ if got, want := killSessionArgs("sess-1"), []string{"kill-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("killSessionArgs = %#v, want %#v", got, want) ++ } ++ if got, want := hasSessionArgs("sess-1"), []string{"has-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("hasSessionArgs = %#v, want %#v", got, want) ++ } ++ if got, want := sendKeysLiteralArgs("sess-1", "hello"), []string{"send-keys", "-t", "sess-1", "-l", "hello"}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("sendKeysLiteralArgs = %#v, want %#v", got, want) ++ } ++ if got, want := sendEnterArgs("sess-1"), []string{"send-keys", "-t", "sess-1", "Enter"}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("sendEnterArgs = %#v, want %#v", got, want) ++ } ++ if got, want := capturePaneArgs("sess-1", 10), []string{"capture-pane", "-t", "sess-1", "-p", "-S", "-10"}; !reflect.DeepEqual(got, want) { ++ t.Fatalf("capturePaneArgs = %#v, want %#v", got, want) ++ } ++} ++ ++// -- session name sanitization -- ++ ++func TestSessionNameSanitizesSpecialChars(t *testing.T) { ++ got, err := tmuxSessionName("repo/issue#42.1") ++ if err != nil { ++ t.Fatalf("tmuxSessionName: %v", err) ++ } ++ if !sessionIDPattern.MatchString(got) { ++ t.Fatalf("sanitized id %q fails pattern", got) ++ } ++ if !strings.HasPrefix(got, "repo-issue-42-1-") { ++ t.Fatalf("sanitized id = %q, want readable prefix", got) ++ } ++ if got == "repo/issue#42.1" { ++ t.Fatal("sanitized id still contains raw unsafe characters") ++ } ++} ++ ++func TestSessionNamePassesThroughShortConforming(t *testing.T) { ++ if got := SessionName("myproj-1"); got != "myproj-1" { ++ t.Fatalf("SessionName = %q, want unchanged", got) ++ } ++} ++ ++func TestSessionNameMatchesCreateNaming(t *testing.T) { ++ long := domain.SessionID(strings.Repeat("x", 60) + "-1") ++ viaCreate, err := tmuxSessionName(long) ++ if err != nil { ++ t.Fatalf("tmuxSessionName: %v", err) ++ } ++ if got := SessionName(string(long)); got != viaCreate { ++ t.Fatalf("SessionName = %q, but Create uses %q", got, viaCreate) ++ } ++ if SessionName(string(long)) == string(long) { ++ t.Fatal("expected long id to be sanitised to a different name") ++ } ++} ++ ++// -- env key validation -- ++ ++func TestCreateRejectsInvalidEnvKeys(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ _ = fr ++ _, err := r.Create(context.Background(), ports.RuntimeConfig{ ++ SessionID: "sess-1", ++ WorkspacePath: "/tmp/ws", ++ Argv: []string{"echo", "hi"}, ++ Env: map[string]string{"BAD KEY": "x"}, ++ }) ++ if err == nil || !strings.Contains(err.Error(), "invalid env key") { ++ t.Fatalf("Create err = %v, want invalid env key", err) ++ } ++} ++ ++// -- Create tests -- ++ ++func TestCreateIssuesNewSessionAndStatusOff(t *testing.T) { ++ // new-session output (ignored), set-option output, has-session output (exit 0 = alive) ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{nil, nil, nil} ++ ++ h, err := r.Create(context.Background(), ports.RuntimeConfig{ ++ SessionID: "sess-1", ++ WorkspacePath: "/tmp/ws", ++ Argv: []string{"echo", "hi"}, ++ Env: map[string]string{"AO_SESSION_ID": "sess-1"}, ++ }) ++ if err != nil { ++ t.Fatalf("Create: %v", err) ++ } ++ if h.ID != "sess-1" { ++ t.Fatalf("handle ID = %q, want sess-1", h.ID) ++ } ++ // Expect 3 calls: new-session, set-option, has-session. ++ if len(fr.calls) != 3 { ++ t.Fatalf("calls = %d, want 3", len(fr.calls)) ++ } ++ ++ // Call 0: new-session ++ if got := fr.calls[0].args[0]; got != "new-session" { ++ t.Fatalf("call[0] = %q, want new-session", got) ++ } ++ // Check -s , -c are present. ++ joined := strings.Join(fr.calls[0].args, " ") ++ if !strings.Contains(joined, "-s sess-1") { ++ t.Fatalf("new-session args missing -s sess-1: %v", fr.calls[0].args) ++ } ++ if !strings.Contains(joined, "-c /tmp/ws") { ++ t.Fatalf("new-session args missing -c /tmp/ws: %v", fr.calls[0].args) ++ } ++ // Ensure -x and -y are set. ++ if !strings.Contains(joined, "-x 220") || !strings.Contains(joined, "-y 50") { ++ t.Fatalf("new-session args missing -x/-y: %v", fr.calls[0].args) ++ } ++ ++ // Call 1: set-option status off (plain target, pane-targeting does not use =). ++ if got, want := fr.calls[1].args, setStatusOffArgs("sess-1"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("call[1] = %#v, want %#v", got, want) ++ } ++ ++ // Call 2: has-session (IsAlive, uses exact-match target =sess-1). ++ if got, want := fr.calls[2].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("call[2] = %#v, want %#v", got, want) ++ } ++} ++ ++func TestCreateLaunchCommandContainsKeepAliveShell(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{nil, nil, nil} ++ ++ _, err := r.Create(context.Background(), ports.RuntimeConfig{ ++ SessionID: "sess-1", ++ WorkspacePath: "/tmp/ws", ++ Argv: []string{"myagent", "--flag"}, ++ }) ++ if err != nil { ++ t.Fatalf("Create: %v", err) ++ } ++ // The launch command is the last argument to new-session (after shellPath -c). ++ args := fr.calls[0].args ++ launchCmd := args[len(args)-1] ++ if !strings.Contains(launchCmd, `exec "${SHELL:-/bin/sh}" -i`) { ++ t.Fatalf("launch command missing keep-alive shell: %q", launchCmd) ++ } ++ if !strings.Contains(launchCmd, "'myagent'") { ++ t.Fatalf("launch command missing quoted argv: %q", launchCmd) ++ } ++} ++ ++func TestCreateLaunchCommandExportsEnvVars(t *testing.T) { ++ oldGetenv := getenv ++ getenv = func(key string) string { ++ if key == "PATH" { ++ return "/usr/bin:/bin" ++ } ++ return "" ++ } ++ defer func() { getenv = oldGetenv }() ++ ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{nil, nil, nil} ++ ++ _, err := r.Create(context.Background(), ports.RuntimeConfig{ ++ SessionID: "sess-1", ++ WorkspacePath: "/tmp/ws", ++ Argv: []string{"myagent"}, ++ Env: map[string]string{ ++ "AO_SESSION_ID": "sess-1", ++ "ODD": "can't", ++ "PATH": "/custom/bin:/usr/bin", ++ }, ++ }) ++ if err != nil { ++ t.Fatalf("Create: %v", err) ++ } ++ args := fr.calls[0].args ++ launchCmd := args[len(args)-1] ++ for _, want := range []string{ ++ "export AO_SESSION_ID='sess-1';", ++ "export ODD='can'\\''t';", ++ "export PATH='/custom/bin:/usr/bin';", ++ } { ++ if !strings.Contains(launchCmd, want) { ++ t.Fatalf("launch command missing %q in: %q", want, launchCmd) ++ } ++ } ++} ++ ++func TestCreateDestroysAndReturnsErrorWhenNotAlive(t *testing.T) { ++ // Use a specialized fakeRunner that returns an exit error only for the 3rd call. ++ r2, _ := newTestRuntime(0) ++ fr3 := &fakeRunnerSelectiveErr{exitErrAt: 2} ++ fr3.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} ++ r2.runner = fr3 ++ ++ _, err := r2.Create(context.Background(), ports.RuntimeConfig{ ++ SessionID: "sess-1", ++ WorkspacePath: "/tmp/ws", ++ Argv: []string{"myagent"}, ++ }) ++ if err == nil { ++ t.Fatal("Create: got nil, want error when session not alive after create") ++ } ++ // Verify Destroy was called (kill-session). ++ hasKill := false ++ for _, c := range fr3.calls { ++ if len(c.args) > 0 && c.args[0] == "kill-session" { ++ hasKill = true ++ } ++ } ++ if !hasKill { ++ t.Fatal("expected kill-session cleanup call when session not alive") ++ } ++} ++ ++// fakeRunnerSelectiveErr returns an exec.ExitError for the call at index exitErrAt. ++type fakeRunnerSelectiveErr struct { ++ calls []runnerCall ++ outputs [][]byte ++ exitErrAt int ++} ++ ++func (f *fakeRunnerSelectiveErr) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { ++ idx := len(f.calls) ++ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) ++ var out []byte ++ if len(f.outputs) > 0 { ++ out = f.outputs[0] ++ f.outputs = f.outputs[1:] ++ } ++ if idx == f.exitErrAt { ++ return out, &exec.ExitError{} ++ } ++ return out, nil ++} ++ ++func (f *fakeRunnerSelectiveErr) Start(env []string, name string, args ...string) error { ++ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) ++ if len(f.outputs) > 0 { ++ f.outputs = f.outputs[1:] ++ } ++ return nil ++} ++ ++// -- Destroy tests -- ++ ++func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("can't find session: sess-1")} ++ fr.err = &exec.ExitError{} ++ ++ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { ++ t.Fatalf("Destroy: %v", err) ++ } ++ if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" { ++ t.Fatalf("calls = %#v, want only kill-session", fr.calls) ++ } ++} ++ ++func TestDestroyIsIdempotentWhenNoServer(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("no server running on /tmp/tmux-1000/default")} ++ fr.err = &exec.ExitError{} ++ ++ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { ++ t.Fatalf("Destroy no-server: %v", err) ++ } ++} ++ ++func TestDestroyReportsUnexpectedFailures(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("permission denied")} ++ fr.err = &exec.ExitError{} ++ ++ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err == nil { ++ t.Fatal("Destroy: got nil, want unexpected failure error") ++ } ++} ++ ++func TestDestroyArgs(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{nil} ++ ++ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { ++ t.Fatalf("Destroy: %v", err) ++ } ++ // killSessionArgs uses exact-match target =. ++ if got, want := fr.calls[0].args, killSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("destroy args = %#v, want %#v", got, want) ++ } ++} ++ ++// -- IsAlive tests -- ++ ++func TestIsAliveReturnsTrueOnExitZero(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{nil} ++ ++ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) ++ if err != nil { ++ t.Fatalf("IsAlive: %v", err) ++ } ++ if !alive { ++ t.Fatal("alive = false, want true") ++ } ++ if got, want := fr.calls[0].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("has-session args = %#v, want %#v", got, want) ++ } ++} ++ ++func TestIsAliveReturnsFalseNilOnCantFindSession(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("can't find session: sess-1")} ++ fr.err = &exec.ExitError{} ++ ++ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) ++ if err != nil { ++ t.Fatalf("IsAlive: %v", err) ++ } ++ if alive { ++ t.Fatal("alive = true, want false") ++ } ++} ++ ++func TestIsAliveReturnsFalseNilOnNoServer(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("no server running on /tmp/tmux-1000/default")} ++ fr.err = &exec.ExitError{} ++ ++ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) ++ if err != nil { ++ t.Fatalf("IsAlive: %v", err) ++ } ++ if alive { ++ t.Fatal("alive = true, want false") ++ } ++} ++ ++func TestIsAliveReturnsFalseNilOnErrorConnecting(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("error connecting to /tmp/tmux-1000/default (No such file or directory)")} ++ fr.err = &exec.ExitError{} ++ ++ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) ++ if err != nil { ++ t.Fatalf("IsAlive error connecting: %v", err) ++ } ++ if alive { ++ t.Fatal("alive = true, want false") ++ } ++} ++ ++// IsAlive must treat any non-"missing" non-zero exit as a probe error so the ++// reaper never reads a transient failure as proof of death. ++func TestIsAliveReportsOtherExitFailuresAsProbeErrors(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("unexpected internal error")} ++ fr.err = &exec.ExitError{} ++ ++ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) ++ if err == nil { ++ t.Fatal("IsAlive: got nil, want probe error; failed probe must not read as dead") ++ } ++ if alive { ++ t.Fatal("alive = true on probe failure") ++ } ++} ++ ++// -- SendMessage tests -- ++ ++func TestSendMessageChunksAndSendsEnter(t *testing.T) { ++ r, fr := newTestRuntime(5) // chunkSize=5 ++ // "hello世界": hello=5 bytes, 世=3 bytes, 界=3 bytes => 3 sends + 1 Enter ++ if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, "hello世界"); err != nil { ++ t.Fatalf("SendMessage: %v", err) ++ } ++ if len(fr.calls) != 4 { ++ t.Fatalf("calls = %d, want 4 (3 chunks + Enter)", len(fr.calls)) ++ } ++ if got, want := fr.calls[0].args, sendKeysLiteralArgs("sess-1", "hello"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("chunk 1 args = %#v, want %#v", got, want) ++ } ++ if got, want := fr.calls[1].args, sendKeysLiteralArgs("sess-1", "世"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("chunk 2 args = %#v, want %#v", got, want) ++ } ++ if got, want := fr.calls[2].args, sendKeysLiteralArgs("sess-1", "界"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("chunk 3 args = %#v, want %#v", got, want) ++ } ++ if got, want := fr.calls[3].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) { ++ t.Fatalf("Enter args = %#v, want %#v", got, want) ++ } ++} ++ ++func TestSendMessageUsesLiteralFlag(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, "Enter"); err != nil { ++ t.Fatalf("SendMessage: %v", err) ++ } ++ // First call must use -l so "Enter" is sent literally, not as a key binding. ++ if fr.calls[0].args[3] != "-l" { ++ t.Fatalf("send-keys args[3] = %q, want -l", fr.calls[0].args[3]) ++ } ++} ++ ++// -- GetOutput tests -- ++ ++func TestGetOutputValidatesLines(t *testing.T) { ++ r, _ := newTestRuntime(0) ++ _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 0) ++ if err == nil { ++ t.Fatal("GetOutput lines=0: got nil, want error") ++ } ++} ++ ++func TestGetOutputTrimsLines(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("one\ntwo\nthree\n")} ++ ++ out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 2) ++ if err != nil { ++ t.Fatalf("GetOutput: %v", err) ++ } ++ if out != "two\nthree\n" { ++ t.Fatalf("output = %q, want last two lines", out) ++ } ++} ++ ++func TestGetOutputTrimsTrailingScreenPaddingBeforeTailing(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("ready\nprompt> echo hi\nhi\n\n\n\n")} ++ ++ out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 2) ++ if err != nil { ++ t.Fatalf("GetOutput: %v", err) ++ } ++ if out != "prompt> echo hi\nhi\n" { ++ t.Fatalf("output = %q, want last non-padding lines", out) ++ } ++} ++ ++func TestGetOutputArgs(t *testing.T) { ++ r, fr := newTestRuntime(0) ++ fr.outputs = [][]byte{[]byte("output\n")} ++ ++ _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 10) ++ if err != nil { ++ t.Fatalf("GetOutput: %v", err) ++ } ++ if got, want := fr.calls[0].args, capturePaneArgs("sess-1", 10); !reflect.DeepEqual(got, want) { ++ t.Fatalf("capture-pane args = %#v, want %#v", got, want) ++ } ++} ++ ++// -- AttachCommand tests -- ++ ++func TestAttachCommandReturnsExpectedArgv(t *testing.T) { ++ r := New(Options{Binary: "/usr/bin/tmux", Timeout: time.Second}) ++ argv, env, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1"}) ++ if err != nil { ++ t.Fatalf("AttachCommand: %v", err) ++ } ++ want := []string{"/usr/bin/tmux", "attach-session", "-t", "sess-1"} ++ if !reflect.DeepEqual(argv, want) { ++ t.Fatalf("argv = %#v, want %#v", argv, want) ++ } ++ if env != nil { ++ t.Fatalf("env = %#v, want nil", env) ++ } ++} ++ ++func TestAttachCommandRejectsInvalidHandle(t *testing.T) { ++ r := New(Options{}) ++ _, _, err := r.AttachCommand(ports.RuntimeHandle{ID: ""}) ++ if err == nil { ++ t.Fatal("AttachCommand empty handle: got nil, want error") ++ } ++} ++ ++// -- commandError tests -- ++ ++func TestCommandErrorUnwraps(t *testing.T) { ++ base := errors.New("base") ++ err := commandError{err: base, output: "details"} ++ if !errors.Is(err, base) { ++ t.Fatal("commandError should unwrap base error") ++ } ++ if !strings.Contains(err.Error(), "details") { ++ t.Fatalf("error = %q, want output details", err.Error()) ++ } ++} ++ ++// -- text helper tests -- ++ ++func TestChunks(t *testing.T) { ++ if got := chunks("", 5); !reflect.DeepEqual(got, []string{""}){ ++ t.Fatalf("chunks empty = %#v", got) ++ } ++ if got := chunks("hello", 10); !reflect.DeepEqual(got, []string{"hello"}) { ++ t.Fatalf("chunks fits = %#v", got) ++ } ++ // UTF-8 boundary: 世 is 3 bytes; with chunkSize=5 "hello世界" splits at 5,6,6 ++ got := chunks("hello世界", 5) ++ if len(got) != 3 { ++ t.Fatalf("chunks count = %d, want 3: %#v", len(got), got) ++ } ++ if got[0] != "hello" || got[1] != "世" || got[2] != "界" { ++ t.Fatalf("chunks = %#v, want [hello 世 界]", got) ++ } ++} ++ ++func TestTailLines(t *testing.T) { ++ if got := tailLines("a\nb\nc\n", 2); got != "b\nc\n" { ++ t.Fatalf("tailLines = %q, want b/c", got) ++ } ++ if got := tailLines("a\nb\n", 5); got != "a\nb\n" { ++ t.Fatalf("tailLines fewer = %q", got) ++ } ++ if got := tailLines("", 5); got != "" { ++ t.Fatalf("tailLines empty = %q", got) ++ } ++} ++ ++func TestTrimTrailingBlankLines(t *testing.T) { ++ if got := trimTrailingBlankLines("a\nb\n\n\n"); got != "a\nb\n" { ++ t.Fatalf("trimTrailingBlankLines = %q, want a/b", got) ++ } ++ if got := trimTrailingBlankLines(""); got != "" { ++ t.Fatalf("trimTrailingBlankLines empty = %q", got) ++ } ++} +diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go +index 60b0e29..a52fa52 100644 +--- a/backend/internal/cli/doctor.go ++++ b/backend/internal/cli/doctor.go +@@ -4,20 +4,21 @@ import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "regexp" ++ "runtime" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + ) +@@ -161,21 +162,21 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { + msg = fmt.Sprintf("%s pid=%d port=%d", msg, st.PID, st.Port) + } + if st.Error != "" { + msg += " (" + st.Error + ")" + } + checks = append(checks, doctorCheck{Level: level, Section: doctorSectionCore, Name: "daemon", Message: msg}) + } + + checks = append(checks, + c.checkGit(ctx), +- c.checkZellij(ctx), ++ c.checkTerminalRuntime(ctx), + c.checkAOBinary(), + ) + for _, harness := range doctorHarnesses { + checks = append(checks, c.checkHarness(ctx, harness)) + } + checks = append(checks, c.checkCodexLaunchFlags(ctx), c.checkGitHubToken(ctx)) + return checks + } + + // checkStore inspects the SQLite store WITHOUT opening or migrating it. The +@@ -283,20 +284,47 @@ func (c *commandContext) checkGit(ctx context.Context) doctorCheck { + cmp, err := compareDottedVersion(version, minGitVersion) + if err != nil { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version unknown: %s)", path, firstOutputLine(out))} + } + if cmp < 0 { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; AO expects >= %s for worktrees)", path, version, minGitVersion)} + } + return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; supports worktrees)", path, version)} + } + ++// checkTerminalRuntime checks for the runtime multiplexer used on this platform: ++// tmux on Darwin/Linux, zellij on Windows. ++func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { ++ if runtime.GOOS == "windows" { ++ return c.checkZellij(ctx) ++ } ++ return c.checkTmux(ctx) ++} ++ ++func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { ++ path, err := c.deps.LookPath("tmux") ++ if err != nil || path == "" { ++ return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"} ++ } ++ reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) ++ defer cancel() ++ out, err := c.deps.CommandOutput(reqCtx, path, "-V") ++ if err != nil { ++ return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s: %v", path, err)} ++ } ++ version := firstOutputLine(out) ++ if version == "" { ++ version = "version unknown" ++ } ++ return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s)", path, version)} ++} ++ + func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { + path, err := c.deps.LookPath("zellij") + if err != nil || path == "" { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "zellij", Message: "not found in PATH"} + } + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + out, err := c.deps.CommandOutput(reqCtx, path, "--version") + if err != nil { + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} +diff --git a/backend/internal/cli/doctor_test.go b/backend/internal/cli/doctor_test.go +index c1bf169..2e4acf0 100644 +--- a/backend/internal/cli/doctor_test.go ++++ b/backend/internal/cli/doctor_test.go +@@ -45,67 +45,69 @@ func TestDoctorWarnsOnUnsupportedGitVersion(t *testing.T) { + func TestDoctorFailsWhenGitMissing(t *testing.T) { + setConfigEnv(t) + c := doctorContext(t, map[string]string{}, nil) + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "git") + if check.Level != doctorFail { + t.Fatalf("git check = %+v, want FAIL", check) + } + } + +-func TestDoctorChecksZellijVersion(t *testing.T) { ++func TestDoctorChecksTmuxVersion(t *testing.T) { + setConfigEnv(t) +- c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, args ...string) ([]byte, error) { ++ c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, args ...string) ([]byte, error) { + switch name { + case "/bin/git": + return []byte("git version 2.43.0\n"), nil +- case "/bin/zellij": +- if len(args) != 1 || args[0] != "--version" { +- t.Fatalf("unexpected zellij command: %s %v", name, args) ++ case "/bin/tmux": ++ if len(args) != 1 || args[0] != "-V" { ++ t.Fatalf("unexpected tmux command: %s %v", name, args) + } +- return []byte("zellij 0.44.3\n"), nil ++ return []byte("tmux 3.3a\n"), nil + default: + t.Fatalf("unexpected command: %s %v", name, args) + return nil, nil + } + }) + +- check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") +- if check.Level != doctorPass || !strings.Contains(check.Message, "0.44.3") { +- t.Fatalf("zellij check = %+v, want PASS with version", check) ++ check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") ++ if check.Level != doctorPass || !strings.Contains(check.Message, "3.3a") { ++ t.Fatalf("tmux check = %+v, want PASS with version", check) + } + } + +-func TestDoctorFailsUnsupportedZellijVersion(t *testing.T) { ++// TestDoctorChecksTmuxVersionFailsOnError covers the case where tmux is found ++// but the version command fails. ++func TestDoctorChecksTmuxVersionFailsOnError(t *testing.T) { + setConfigEnv(t) +- c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { ++ c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "/bin/git" { + return []byte("git version 2.43.0\n"), nil + } +- return []byte("zellij 0.44.2\n"), nil ++ return nil, errors.New("exec: tmux: not found") + }) + +- check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") +- if check.Level != doctorFail || !strings.Contains(check.Message, "require >= 0.44.3") { +- t.Fatalf("zellij check = %+v, want FAIL with minimum version", check) ++ check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") ++ if check.Level != doctorFail { ++ t.Fatalf("tmux check = %+v, want FAIL on version error", check) + } + } + +-func TestDoctorWarnsWhenZellijMissing(t *testing.T) { ++func TestDoctorWarnsWhenTmuxMissing(t *testing.T) { + setConfigEnv(t) + c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { + return []byte("git version 2.43.0\n"), nil + }) + +- check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") ++ check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") + if check.Level != doctorWarn { +- t.Fatalf("zellij check = %+v, want WARN", check) ++ t.Fatalf("tmux check = %+v, want WARN", check) + } + } + + func TestDoctorChecksHarnessVersions(t *testing.T) { + setConfigEnv(t) + cmdPath := map[string]string{ + "git": "/bin/git", + "claude": "/bin/claude", + "codex": "/bin/codex", + } +diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go +index b5cc717..ae7bac4 100644 +--- a/backend/internal/cli/spawn.go ++++ b/backend/internal/cli/spawn.go +@@ -1,20 +1,22 @@ + package cli + + import ( + "context" + "fmt" + "net/url" ++ "runtime" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + ) + + type spawnOptions struct { + project string + harness string + branch string + prompt string + issue string + claimPR string +@@ -90,28 +92,31 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { + } + } + out := cmd.OutOrStdout() + claimLabel := "" + if claimed != "" { + claimLabel = fmt.Sprintf(" (claimed %s)", claimed) + } + if _, err := fmt.Fprintf(out, "spawned session %s (%s)%s\n", res.Session.ID, res.Session.Status, claimLabel); err != nil { + return err + } +- // The daemon runs zellij under a short, non-default socket dir (see +- // zellij.DefaultSocketDir), so a plain `zellij attach` wouldn't find +- // the session — prefix the env so the hint is copy-pasteable. Use the +- // sanitised name zellij actually registers (zellij.SessionName): a long +- // session id maps to a different name than the raw id. +- attach := fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) +- if dir := zellij.DefaultSocketDir(); dir != "" { +- attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) ++ // Print a copy-pasteable attach hint for the selected runtime. ++ // On Darwin/Linux: tmux attach-session using the sanitised session name. ++ // On Windows: zellij attach with the short socket dir prefix. ++ var attach string ++ if runtime.GOOS != "windows" { ++ attach = fmt.Sprintf("tmux attach -t %s", tmux.SessionName(res.Session.ID)) ++ } else { ++ attach = fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) ++ if dir := zellij.DefaultSocketDir(); dir != "" { ++ attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) ++ } + } + _, err := fmt.Fprintf(out, "attach with: %s\n", attach) + return err + }, + } + f := cmd.Flags() + // --agent is an alias for --harness so the more intuitive `ao spawn --agent + // droid` works identically; both resolve to the same harness flag. + f.SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName { + if name == "agent" { +diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go +index 59791c8..29ca5ef 100644 +--- a/backend/internal/daemon/daemon.go ++++ b/backend/internal/daemon/daemon.go +@@ -6,21 +6,21 @@ package daemon + import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + +- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/notify" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/preview" + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" + "github.com/aoagents/agent-orchestrator/backend/internal/terminal" +@@ -75,55 +75,45 @@ func Run() error { + // signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the + // graceful shutdown inside Server.Run and stops the background goroutines. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + cdcPipe, err := startCDC(ctx, store, log) + if err != nil { + return err + } + +- // Terminal streaming: the Zellij runtime supplies the PTY-attach command and +- // liveness; the CDC broadcaster feeds the session-state channel. The manager ++ // Terminal streaming: the selected runtime (tmux on macOS/Linux, zellij on Windows) supplies the ++ // PTY-attach command and liveness; the CDC broadcaster feeds the session-state channel. The manager + // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow +- // through the CDC change_log — only session-state events do. +- // zellij's default socket dir is too long on macOS for long session ids +- // (see zellij.DefaultSocketDir); use a short, stable one and ensure it exists. +- zellijSocketDir := zellij.DefaultSocketDir() +- if zellijSocketDir != "" { +- if err := os.MkdirAll(zellijSocketDir, 0o700); err != nil { +- // Don't abort startup, but surface it: every spawn's zellij session +- // would otherwise fail later with an opaque socket-bind error. +- log.Warn("could not create zellij socket dir; spawns may fail", "dir", zellijSocketDir, "error", err) +- } +- } +- runtimeAdapter := zellij.New(zellij.Options{SocketDir: zellijSocketDir}) ++ // through the CDC change_log -- only session-state events do. ++ runtimeAdapter := runtimeselect.New(log) + termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) + defer termMgr.Close() + + // The agent messenger sends validated user input to the session's live +- // zellij pane. Keep this path small until durable inbox semantics are needed. ++ // runtime pane. Keep this path small until durable inbox semantics are needed. + // Built before the Lifecycle Manager so the LCM can use it for SCM-driven + // agent nudges (CI failure, review feedback, merge conflict). + messenger := newSessionMessenger(store, runtimeAdapter, log) + notificationHub := notify.NewHub() + notifier := notificationsvc.New(notificationsvc.Deps{Store: store}) + notificationWriter := notify.New(notify.Deps{Store: store, Publisher: notificationHub}) + + // Bring up the Lifecycle Manager and the reaper first: it makes the session + // lifecycle write path live (reducer write -> store -> DB trigger -> + // change_log -> poller -> broadcaster) and gives startSession the shared LCM. + lcStack := startLifecycle(ctx, store, runtimeAdapter, messenger, notificationWriter, telemetrySink, log) + lcStack.scmDone = startSCMObserver(ctx, store, lcStack.LCM, log) + + // Wire the controller-facing session service over the same store + LCM, the +- // zellij runtime, a gitworktree workspace, the per-session agent resolver ++ // selected runtime, a gitworktree workspace, the per-session agent resolver + // (AO_AGENT validated here for compatibility), and the agent messenger, then mount it + // on the API. + sessionSvc, reviewSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, telemetrySink, log) + if err != nil { + stop() + lcStack.Stop() + if cdcErr := cdcPipe.Stop(); cdcErr != nil { + log.Error("cdc pipeline shutdown", "err", cdcErr) + } + return fmt.Errorf("wire session service: %w", err) +diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go +index 3bf5ff7..5d21160 100644 +--- a/backend/internal/daemon/lifecycle_wiring.go ++++ b/backend/internal/daemon/lifecycle_wiring.go +@@ -3,21 +3,21 @@ package daemon + import ( + "context" + "fmt" + "log/slog" + "path/filepath" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitydispatch" + agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/reviewer" +- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" + reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" +@@ -52,24 +52,24 @@ func startLifecycle(ctx context.Context, store *sqlite.Store, runtime ports.Runt + // Stop waits for the reaper goroutine to exit. The caller must cancel the ctx + // passed to startLifecycle before calling Stop. + func (l *lifecycleStack) Stop() { + <-l.reaperDone + if l.scmDone != nil { + <-l.scmDone + } + } + + // startSession builds the controller-facing session service: a session manager +-// over the real zellij runtime, a per-session gitworktree workspace, the shared ++// over the selected runtime, a per-session gitworktree workspace, the shared + // store + LCM, the per-session agent resolver, and the agent messenger. The + // returned service is mounted at httpd APIDeps.Sessions. +-func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { ++func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { + defaultAgent := cfg.Agent + if defaultAgent == "" { + defaultAgent = config.DefaultAgent + } + agents, err := buildAgentResolver(defaultAgent, log) + if err != nil { + return nil, nil, err + } + ws, err := gitworktree.New(gitworktree.Options{ + // Per-session worktrees live under the data dir, so a single AO_DATA_DIR +diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go +index a3acd55..21bd248 100644 +--- a/backend/internal/daemon/wiring_test.go ++++ b/backend/internal/daemon/wiring_test.go +@@ -3,20 +3,21 @@ package daemon + import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + telemetryadapter "github.com/aoagents/agent-orchestrator/backend/internal/adapters/telemetry" + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" + ) +@@ -142,23 +143,23 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + lcm := lifecycle.New(store, nil) + cfg := config.Config{DataDir: t.TempDir()} + +- runtime := zellij.New(zellij.Options{}) +- messenger := newSessionMessenger(store, runtime, log) +- svc, reviewSvc, err := startSession(cfg, runtime, store, lcm, messenger, telemetryadapter.NoopSink{}, log) ++ rt := runtimeselect.New(nil) ++ messenger := newSessionMessenger(store, rt, log) ++ svc, reviewSvc, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) + if err != nil { + t.Fatalf("startSession: %v", err) + } + if svc == nil { + t.Fatal("startSession returned nil session service") + } + if reviewSvc == nil { + t.Fatal("startSession returned nil review service") + } + } + +--- Changes --- + +backend/internal/adapters/runtime/runtimeselect/runtimeselect.go + @@ -0,0 +1,46 @@ + +// Package runtimeselect picks the correct runtime backend by platform: + +// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). + +package runtimeselect + + + +import ( + + "context" + + "log/slog" + + "os" + + "runtime" + + + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + +) + + + +// Runtime is the union interface that both tmux and zellij satisfy. + +// It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods + +// the daemon wires directly. + +type Runtime interface { + + ports.Runtime // Create, Destroy, IsAlive + + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error + + GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) + + AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) + +} + + + +// Compile-time assertions: both adapters must implement the union interface. + +var _ Runtime = (*tmux.Runtime)(nil) + +var _ Runtime = (*zellij.Runtime)(nil) + + + +// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. + +// log is used only for the Windows zellij socket-dir warning; nil is safe. + +func New(log *slog.Logger) Runtime { + + if runtime.GOOS != "windows" { + + return tmux.New(tmux.Options{}) + + } + + // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. + + dir := zellij.DefaultSocketDir() + + if dir != "" { + + if err := os.MkdirAll(dir, 0o700); err != nil { + + if log != nil { + + log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) + + } + + } + + } + + return zellij.New(zellij.Options{SocketDir: dir}) + +} + +46 -0 + +backend/internal/adapters/runtime/tmux/commands.go + @@ -0,0 +1,64 @@ + +package tmux + + + +import "fmt" + + + +// newSessionArgs builds args for `tmux new-session -d -s -x 220 -y 50 + +// -c -c `. The shell -c form runs the launch command + +// inside the configured shell so exported env vars and quoting work correctly. + +func newSessionArgs(id, cwd, shellPath, launchCmd string) []string { + + return []string{ + + "new-session", "-d", + + "-s", id, + + "-x", "220", + + "-y", "50", + + "-c", cwd, + + shellPath, "-c", launchCmd, + + } + +} + + + +// setStatusOffArgs hides the tmux status bar for the given session. + +// set-option uses pane-targeting syntax which does not accept the `=` prefix, + +// so we pass the session name directly. + +func setStatusOffArgs(id string) []string { + + return []string{"set-option", "-t", id, "status", "off"} + +} + + + +// killSessionArgs builds args for `tmux kill-session -t =`. The `=` prefix + +// requests exact-name matching so a session "foo" does not accidentally match + +// "foobar" (tmux otherwise does unique-prefix matching). + +func killSessionArgs(id string) []string { + + return []string{"kill-session", "-t", exactSessionTarget(id)} + +} + + + +// hasSessionArgs builds args for `tmux has-session -t =`. The `=` prefix + +// requests exact-name matching (see killSessionArgs). + +func hasSessionArgs(id string) []string { + + return []string{"has-session", "-t", exactSessionTarget(id)} + +} + + + +// exactSessionTarget wraps id in tmux's exact-match prefix `=` so session- + +// selection commands (-t) target only the session with that precise name. + +// Only kill-session and has-session support this prefix; pane-targeting + +// commands (send-keys, capture-pane, set-option) use a plain session name. + +func exactSessionTarget(id string) string { + + return "=" + id + +} + + + +// sendKeysLiteralArgs builds args for `tmux send-keys -t -l `. + +// The -l flag stops tmux interpreting words like "Enter" as key names so the + +// text is sent verbatim. + +func sendKeysLiteralArgs(id, chunk string) []string { + + return []string{"send-keys", "-t", id, "-l", chunk} + +} + + + +// sendEnterArgs builds args for `tmux send-keys -t Enter` to submit the + +// queued input. + +func sendEnterArgs(id string) []string { + + return []string{"send-keys", "-t", id, "Enter"} + +} + + + +// capturePaneArgs builds args for `tmux capture-pane -t -p -S -`. + +// -p prints to stdout; -S - starts n lines back in history. + +func capturePaneArgs(id string, lines int) []string { + + return []string{"capture-pane", "-t", id, "-p", "-S", fmt.Sprintf("-%d", lines)} + +} + +64 -0 + +backend/internal/adapters/runtime/tmux/tmux.go + @@ -0,0 +1,482 @@ + +// Package tmux implements ports.Runtime using tmux sessions on Darwin/Linux. + +package tmux + + + +import ( + + "context" + + "crypto/sha256" + + "encoding/hex" + + "errors" + + "fmt" + + "os" + + "os/exec" + + "regexp" + + "sort" + + "strings" + + "time" + + "unicode/utf8" + + + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + +) + + + +const ( + + defaultTimeout = 5 * time.Second + + defaultChunkBytes = 16 * 1024 + +) + + + +var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + + + +var getenv = os.Getenv + + + +// Options configures a tmux Runtime. Every field has a sensible default (see + +// New), so the zero value is usable. + +type Options struct { + + Binary string // default "tmux" (resolved via exec.LookPath) + + Shell string // default $SHELL else /bin/sh + + Timeout time.Duration // default 5s + + ChunkSize int // default 16*1024 + +} + + + +// Runtime runs agent sessions inside tmux sessions, driving them via the tmux + +// CLI. It implements ports.Runtime. + +type Runtime struct { + + binary string + + shell string + + timeout time.Duration + + chunkSize int + + runner runner + +} + + + +var _ ports.Runtime = (*Runtime)(nil) + + + +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 = append(append([]string(nil), os.Environ()...), env...) + + return cmd.CombinedOutput() + +} + + + +func (execRunner) Start(env []string, name string, args ...string) error { + + cmd := exec.Command(name, args...) + + cmd.Env = append(append([]string(nil), os.Environ()...), env...) + + return cmd.Start() + +} + + + +// New builds a tmux Runtime, filling unset Options with defaults: binary "tmux" + +// (resolved via exec.LookPath), shell from $SHELL (else /bin/sh), and the + +// default timeout and output chunk size. + +func New(opts Options) *Runtime { + + binary := opts.Binary + + if binary == "" { + + if path, err := exec.LookPath("tmux"); err == nil { + + binary = path + + } else { + + binary = "tmux" + + } + + } + + timeout := opts.Timeout + + if timeout == 0 { + + timeout = defaultTimeout + + } + + shellPath := opts.Shell + + if shellPath == "" { + + shellPath = getenv("SHELL") + + } + + if shellPath == "" { + + shellPath = "/bin/sh" + + } + + chunkSize := opts.ChunkSize + + if chunkSize <= 0 { + + chunkSize = defaultChunkBytes + + } + + return &Runtime{ + + binary: binary, + + shell: shellPath, + ... (382 lines truncated) + +482 -0 + +backend/internal/adapters/runtime/tmux/tmux_integration_test.go + @@ -0,0 +1,143 @@ + +package tmux + + + +import ( + + "context" + + "os/exec" + + "strings" + + "testing" + + "time" + + + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + +) + + + +func TestRuntimeIntegration(t *testing.T) { + + if _, err := exec.LookPath("tmux"); err != nil { + + t.Skip("tmux unavailable") + + } + + + + ctx := context.Background() + + id := strings.ReplaceAll(t.Name(), "/", "_") + + r := New(Options{Timeout: 5 * time.Second}) + + + + // Ensure clean slate: ignore errors (session may not exist). + + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) + + + + t.Cleanup(func() { + + // Always destroy so a test failure never leaks a tmux session. + + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) + + }) + + + + h, err := r.Create(ctx, ports.RuntimeConfig{ + + SessionID: domain.SessionID(id), + + WorkspacePath: t.TempDir(), + + // Run a trivial command then drop into an interactive shell (the keep-alive + + // exec is added by buildLaunchCommand, but we also verify here that output + + // appears). + + Argv: []string{"sh", "-c", "echo hello-from-tmux"}, + + Env: map[string]string{"AO_SESSION_ID": id}, + + }) + + if err != nil { + + t.Fatalf("Create: %v", err) + + } + + + + alive, err := r.IsAlive(ctx, h) + + if err != nil { + + t.Fatalf("IsAlive: %v", err) + + } + + if !alive { + + t.Fatal("alive = false, want true after create") + + } + + + + // Wait for the echo output to appear (the session may take a moment to + + // write it to the pane history). + + out := waitForOutput(t, r, h, "hello-from-tmux", 5*time.Second) + + if !strings.Contains(out, "hello-from-tmux") { + + t.Fatalf("output = %q, want hello-from-tmux", out) + + } + + + + // Send a command and verify it echoes back. + + if err := r.SendMessage(ctx, h, "echo hello-send"); err != nil { + + t.Fatalf("SendMessage: %v", err) + + } + + out = waitForOutput(t, r, h, "hello-send", 5*time.Second) + + if !strings.Contains(out, "hello-send") { + + t.Fatalf("output after SendMessage = %q, want hello-send", out) + + } + + + + // Destroy and verify liveness goes false. + + if err := r.Destroy(ctx, h); err != nil { + + t.Fatalf("Destroy: %v", err) + + } + + alive, err = r.IsAlive(ctx, h) + + if err != nil { + + t.Fatalf("IsAlive after destroy: %v", err) + + } + + if alive { + + t.Fatal("alive after destroy = true, want false") + + } + +} + + + +// TestRuntimeIntegrationExactSessionParsing verifies that IsAlive uses exact + +// session matching and does not treat a prefix as a live session. + +func TestRuntimeIntegrationExactSessionParsing(t *testing.T) { + + if _, err := exec.LookPath("tmux"); err != nil { + + t.Skip("tmux unavailable") + + } + + + + ctx := context.Background() + + base := strings.ReplaceAll(t.Name(), "/", "_") + + longID := base + "_long" + + prefixID := base + + + + r := New(Options{Timeout: 5 * time.Second}) + + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) + + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID}) + + + + t.Cleanup(func() { + + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: longID}) + + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: prefixID}) + + }) + ... (43 lines truncated) + +143 -0 + +backend/internal/adapters/runtime/tmux/tmux_test.go + @@ -0,0 +1,616 @@ + +package tmux + + + +import ( + + "context" + + "errors" + + "os/exec" + + "reflect" + + "strings" + + "testing" + + "time" + + + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + +) + + + +// -- fakeRunner test seam (mirrors zellij_test.go exactly) -- + + + +type fakeRunner struct { + + calls []runnerCall + + outputs [][]byte + + err error + +} + + + +type runnerCall struct { + + env []string + + name string + + args []string + +} + + + +func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { + + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) + + var out []byte + + if len(f.outputs) > 0 { + + out = f.outputs[0] + + f.outputs = f.outputs[1:] + + } + + if f.err != nil { + + return out, f.err + + } + + return out, nil + +} + + + +func (f *fakeRunner) Start(env []string, name string, args ...string) error { + + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) + + if len(f.outputs) > 0 { + + f.outputs = f.outputs[1:] + + } + + return f.err + +} + + + +// -- helpers -- + + + +func newTestRuntime(chunkSize int) (*Runtime, *fakeRunner) { + + fr := &fakeRunner{} + + r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/sh", ChunkSize: chunkSize}) + + r.runner = fr + + return r, fr + +} + + + +// -- Options / New tests -- + + + +func TestNewDefaultsToPortableShell(t *testing.T) { + + t.Setenv("SHELL", "") + + r := New(Options{}) + + if got := r.shell; got != "/bin/sh" { + + t.Fatalf("default shell = %q, want /bin/sh", got) + + } + +} + + + +func TestNewPicksUpShellFromEnv(t *testing.T) { + + t.Setenv("SHELL", "/bin/zsh") + + r := New(Options{}) + + if got := r.shell; got != "/bin/zsh" { + + t.Fatalf("shell = %q, want /bin/zsh", got) + + } + +} + + + +// -- command builder tests -- + + + +func TestCommandBuilders(t *testing.T) { + + if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/sh", `echo hi; exec "${SHELL:-/bin/sh}" -i`), + + []string{"new-session", "-d", "-s", "sess-1", "-x", "220", "-y", "50", "-c", "/tmp/ws", "/bin/sh", "-c", `echo hi; exec "${SHELL:-/bin/sh}" -i`}; !reflect.DeepEqual(got, want) { + + t.Fatalf("newSessionArgs = %#v, want %#v", got, want) + + } + + // set-option uses pane-targeting (no = prefix). + + if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "sess-1", "status", "off"}; !reflect.DeepEqual(got, want) { + + t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want) + + } + + // kill-session and has-session use exact-match prefix =. + + if got, want := killSessionArgs("sess-1"), []string{"kill-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { + + t.Fatalf("killSessionArgs = %#v, want %#v", got, want) + + } + + if got, want := hasSessionArgs("sess-1"), []string{"has-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { + + t.Fatalf("hasSessionArgs = %#v, want %#v", got, want) + + } + + if got, want := sendKeysLiteralArgs("sess-1", "hello"), []string{"send-keys", "-t", "sess-1", "-l", "hello"}; !reflect.DeepEqual(got, want) { + + t.Fatalf("sendKeysLiteralArgs = %#v, want %#v", got, want) + + } + + if got, want := sendEnterArgs("sess-1"), []string{"send-keys", "-t", "sess-1", "Enter"}; !reflect.DeepEqual(got, want) { + + t.Fatalf("sendEnterArgs = %#v, want %#v", got, want) + ... (516 lines truncated) + +616 -0 + +backend/internal/cli/doctor.go + @@ -4,20 +4,21 @@ import ( + + "runtime" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + ) + @@ -161,21 +162,21 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { + - c.checkZellij(ctx), + + c.checkTerminalRuntime(ctx), + c.checkAOBinary(), + ) + for _, harness := range doctorHarnesses { + checks = append(checks, c.checkHarness(ctx, harness)) + } + checks = append(checks, c.checkCodexLaunchFlags(ctx), c.checkGitHubToken(ctx)) + return checks + } + + // checkStore inspects the SQLite store WITHOUT opening or migrating it. The + @@ -283,20 +284,47 @@ func (c *commandContext) checkGit(ctx context.Context) doctorCheck { + +// checkTerminalRuntime checks for the runtime multiplexer used on this platform: + +// tmux on Darwin/Linux, zellij on Windows. + +func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { + + if runtime.GOOS == "windows" { + + return c.checkZellij(ctx) + + } + + return c.checkTmux(ctx) + +} + + + +func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { + + path, err := c.deps.LookPath("tmux") + + if err != nil || path == "" { + + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"} + + } + + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + + defer cancel() + + out, err := c.deps.CommandOutput(reqCtx, path, "-V") + + if err != nil { + + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s: %v", path, err)} + + } + + version := firstOutputLine(out) + + if version == "" { + + version = "version unknown" + + } + + return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s)", path, version)} + +} + + + func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { + path, err := c.deps.LookPath("zellij") + if err != nil || path == "" { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "zellij", Message: "not found in PATH"} + } + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + out, err := c.deps.CommandOutput(reqCtx, path, "--version") + if err != nil { + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} + +29 -1 + +backend/internal/cli/doctor_test.go + @@ -45,67 +45,69 @@ func TestDoctorWarnsOnUnsupportedGitVersion(t *testing.T) { + -func TestDoctorChecksZellijVersion(t *testing.T) { + +func TestDoctorChecksTmuxVersion(t *testing.T) { + setConfigEnv(t) + - c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, args ...string) ([]byte, error) { + + c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, args ...string) ([]byte, error) { + +... (more changes truncated) + +2 -2 +[full diff: rtk git diff --no-compact] diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index cf470eff..fff89017 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -5,10 +5,38 @@ Base commit: e970f72 ## Phase A (Darwin/Linux, fully testable here) - Task 1: tmux runtime adapter (adapters/runtime/tmux/) — COMPLETE -- Task 2: runtime selection + daemon wiring + doctor/spawn hints — IN PROGRESS +- Task 2: runtime selection + daemon wiring + doctor/spawn hints — COMPLETE ## Ledger Task 1: complete (commits e970f72..44c3e61, review clean — spec PASS, 34 tests green, no em dashes) +Task 2: complete (commits 44c3e61..4b21e4b, review clean — spec PASS, quality APPROVED, 1585 tests green, GOOS=windows builds; cosmetic minors fixed in 4b21e4b) + +## Phase A FINAL: SHIP (whole-branch review verified vs real tmux 3.6b) +- darwin/linux/windows all build; full suite 1585 passed in 75 packages. +- IsAlive dead-vs-transient contract, keep-alive session survival, attach path, selector wiring, union interface all verified. +- Handle-ID format change (bare session id vs zellij session/pane) breaks no consumer (none parse it). +- Dead runner.Start removed (commit 926ee31). HEAD = 926ee31. +- Known follow-up (non-blocking): stale "Zellij" comments in internal/terminal/attachment.go are now runtime-neutral in behavior but Zellij-worded in prose. + +## Phase B (Windows ConPTY) — IN PROGRESS +User directive: port agent-orchestrator's proven detached pty-host + named-pipe + +registry design to Go (NOT the in-process holder). Integration = strategy 2: +evolve terminal PTYSource.AttachCommand -> Attacher.Attach(...) Stream so conpty +dials the pipe directly. Windows-only code can only be compile-checked here. + +Tasks: +- B1: conpty protocol codec + ring buffer (cross-platform, fully unit-tested) — COMPLETE (926ee31..d67941b, 16 tests, -race clean, review APPROVED) +- B2: windows pty-host registry — COMPLETE (..6552e26, 10 tests -race clean, win+linux+darwin build) +- B3: ao pty-host subcommand (go-pty ConPTY + go-winio pipe + fan-out + shutdown) — windows-only, compile-checked — NOT STARTED +- B4: conpty runtime adapter (Create/Destroy/IsAlive/SendMessage/GetOutput/Attach over pipe) — NOT STARTED +- B5: terminal Attacher/Stream interface change + tmux/zellij/conpty Attach impls — Darwin-testable — NOT STARTED +- B6: select conpty on Windows + delete zellij + doctor/spawn + register pty-host subcommand — NOT STARTED + +Faithful-port references (agent-orchestrator): +- packages/plugins/runtime-process/src/pty-host.ts (protocol, ring, fan-out, shutdown) +- packages/plugins/runtime-process/src/pty-client.ts (chunking 512/15ms, Enter 300ms, STATUS probe) +- packages/plugins/runtime-process/src/index.ts (detached spawn, READY handshake, destroy grace) +- packages/core/src/windows-pty-registry.ts (sideband JSON, PID-liveness prune) ## Phase B (Windows, needs a Windows box to verify) — DEFERRED - In-process ConPTY runtime + Attacher/Stream interface change + delete zellij diff --git a/.superpowers/sdd/task-b1-brief.md b/.superpowers/sdd/task-b1-brief.md new file mode 100644 index 00000000..3b85582e --- /dev/null +++ b/.superpowers/sdd/task-b1-brief.md @@ -0,0 +1,119 @@ +# Task B1: conpty protocol codec + output ring buffer (cross-platform, pure Go) + +## Goal +Create the OS-agnostic core of the Windows ConPTY runtime: the named-pipe binary +framing protocol (codec + streaming parser) and the bounded output ring buffer. +This is a faithful Go port of agent-orchestrator's TypeScript implementation. It +has NO OS-specific code and MUST be fully unit-tested and green on this Darwin +machine. No go-pty, no go-winio, no build tags in this task. + +Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, +module in `backend/`, branch `migrate-zellij-to-tmux-conpty` checked out. +Module path prefix: `github.com/aoagents/agent-orchestrator/backend`. + +Create the package: `internal/adapters/runtime/conpty` (this task adds only +`proto.go`, `ring.go`, and their tests; later tasks add the Windows pieces here). + +## Reference (port faithfully — read these) +- `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/agent-orchestrator/packages/plugins/runtime-process/src/pty-host.ts` + lines 33-92 (the MSG_* constants, `encodeMessage`, `MessageParser`) and lines + 138-178 (the rolling output buffer `appendOutput`, MAX_OUTPUT_LINES=1000). + +## Part 1 — Protocol codec (`proto.go`) +Port the binary framing protocol. Frame layout: `[1-byte type][4-byte big-endian +length][payload]`. + +Message type constants (exact values): +```go +const ( + MsgTerminalData byte = 0x01 // host -> client: raw PTY output + MsgTerminalInput byte = 0x02 // client -> host: raw keystrokes + MsgResize byte = 0x03 // client -> host: JSON {cols, rows} + MsgGetOutputReq byte = 0x04 // client -> host: JSON {lines} + MsgGetOutputRes byte = 0x05 // host -> client: UTF-8 text + MsgStatusReq byte = 0x06 // client -> host: empty + MsgStatusRes byte = 0x07 // host -> client: JSON {alive, pid, exitCode?} + MsgKillReq byte = 0x08 // client -> host: empty +) +``` + +Functions/types: +- `func EncodeMessage(msgType byte, payload []byte) []byte` — allocate `5 + + len(payload)`, write type at [0], `binary.BigEndian.PutUint32` the length at + [1:5], copy payload at [5:]. (A string convenience is not needed; callers pass + `[]byte`.) +- `type MessageParser struct { ... }` with: + - `func NewMessageParser(onMessage func(msgType byte, payload []byte)) *MessageParser` + - `func (p *MessageParser) Feed(chunk []byte)` — append to an internal buffer, + then loop: while buffered >= 5, read the BE uint32 length at [1:5], if the + full frame (5+len) is not yet buffered break; otherwise slice type+payload, + advance the buffer past the frame, and invoke onMessage. Port the exact + semantics of the TS `MessageParser.feed` (it handles arbitrary chunk + boundaries and multiple frames per chunk). + - IMPORTANT: hand `onMessage` a COPY of the payload slice (not a sub-slice of + the internal growable buffer), so a caller that retains the payload is not + corrupted when the buffer is later reused/reallocated. (The TS version slices + Buffers which are copy-on-concat; in Go you must copy explicitly.) + +Add a couple of JSON helper structs the later tasks will share (keep minimal): +```go +type ResizePayload struct { Cols int `json:"cols"`; Rows int `json:"rows"` } +type StatusPayload struct { Alive bool `json:"alive"`; PID int `json:"pid"`; ExitCode *int `json:"exitCode,omitempty"` } +type GetOutputReq struct { Lines int `json:"lines"` } +``` + +## Part 2 — Output ring buffer (`ring.go`) +Port the rolling line buffer that preserves raw bytes (ANSI codes intact) for +xterm replay, capped at `MaxOutputLines = 1000`. + +```go +type Ring struct { ... } // guarded by a sync.Mutex; safe for concurrent Append + snapshot +func NewRing() *Ring +func (r *Ring) Append(raw []byte) // port appendOutput: maintain a partialLine, split on '\n', push completed "line\n" entries, trim to last MaxOutputLines +func (r *Ring) Snapshot() []byte // join all buffered lines (for replay to a newly connected client — the full scrollback) +func (r *Ring) Tail(lines int) string // last N lines joined (for GetOutput; mirror the host MSG_GET_OUTPUT_REQ handler: start = max(0, len-lines)) +func (r *Ring) FlushPartial() // on PTY exit, push any trailing partialLine (no newline) as a final entry +``` +Semantics to match the TS `appendOutput` exactly: +- Maintain `partialLine string`. On Append: `text := partialLine + string(raw)`, + split on "\n"; the last split element is the new `partialLine` (either "" if + text ended in \n, or a partial); each earlier element is stored WITH its + trailing "\n" re-appended (`line + "\n"`). Trim oldest entries so stored line + count <= MaxOutputLines. +- `Snapshot` returns `[]byte` of all stored lines concatenated (this is what the + host sends a new client as scrollback). Do NOT include the in-progress + partialLine in Snapshot (matches TS, which only joins outputBuffer). +- `Tail(n)` joins the last n stored lines. + +## Tests (REQUIRED — the runnable check, all run on Darwin) +`proto_test.go`: +- EncodeMessage produces correct bytes for a known type+payload (assert the 5-byte + header and payload). +- MessageParser round-trips: feed a single full frame; feed two frames in one + chunk; feed a frame split across multiple Feed calls (1 byte at a time) and + confirm exactly one onMessage with correct type+payload; feed interleaved + frames of different types. Confirm payload is a copy (mutate the fed slice after + Feed and assert the delivered payload is unchanged). +- A zero-length payload frame (e.g. MsgStatusReq) parses correctly. +`ring_test.go`: +- Append partial then complete lines; Snapshot/Tail reflect the TS semantics. +- Exceeding MaxOutputLines trims the oldest. +- FlushPartial pushes a trailing no-newline line. +- Tail(n) with n greater than stored count returns all; n<=0 returns "". +- Raw bytes incl. ANSI escape sequences survive round-trip (store `\x1b[31mhi\x1b[0m\n`). + +## Definition of done (run from `backend/`) +- `go build ./...` succeeds. +- `go test ./internal/adapters/runtime/conpty/...` passes. +- `go vet ./internal/adapters/runtime/conpty/...` is clean. +- `GOOS=windows go build ./...` still succeeds (this task adds no Windows code, so + it must not break the Windows build either). + +## Hard rules +- Pure Go, no new dependencies, no build tags, no OS-specific calls in this task. +- Never use em dashes ("—") in code, comments, or commit messages. +- Minimal and idiomatic (ponytail); match the surrounding repo style. Mark any + deliberate shortcut with a `ponytail:` comment. +- Touch only files under `backend/internal/adapters/runtime/conpty/`. +- Commit on the current branch; message ends with the + `Co-Authored-By: Claude Opus 4.8 ` trailer (no em dashes). diff --git a/.superpowers/sdd/task-b1-report.md b/.superpowers/sdd/task-b1-report.md new file mode 100644 index 00000000..f443fed0 --- /dev/null +++ b/.superpowers/sdd/task-b1-report.md @@ -0,0 +1,126 @@ +# Task B1 Report: ConPTY Protocol Codec + Output Ring Buffer + +## Status: DONE + +Commit: `1050542` + +## Files created + +- `backend/internal/adapters/runtime/conpty/proto.go` -- constants, EncodeMessage, MessageParser, shared JSON structs +- `backend/internal/adapters/runtime/conpty/ring.go` -- Ring (Append, Snapshot, Tail, FlushPartial) +- `backend/internal/adapters/runtime/conpty/proto_test.go` -- 8 table-driven tests +- `backend/internal/adapters/runtime/conpty/ring_test.go` -- 7 table-driven tests + +## Design notes + +### proto.go + +- `EncodeMessage` allocates `5+len(payload)` bytes, writes the type byte at [0], + big-endian uint32 length at [1:5], copies payload at [5:]. Exact match of + `encodeMessage` in pty-host.ts. +- `MessageParser.Feed` appends to an internal `[]byte` buffer and loops while + `len(buf) >= 5`, reading the frame length, breaking if incomplete, then + slicing type+payload, advancing the buffer past the frame, and calling + onMessage. A `make+copy` is used for the payload (not a sub-slice) so callers + that retain the slice are not corrupted on future buffer growth. +- JSON helper structs kept minimal per brief. + +### ring.go + +- `Append` prepends `partialLine`, splits on `"\n"`, stores all but the last + element as `line+"\n"`, stores the last element as the new `partialLine`. + Trims to `MaxOutputLines` after each Append. Exact match of `appendOutput`. +- `Snapshot` joins all stored lines (excludes `partialLine`), matches the + TS `outputBuffer.join("")` used for scrollback. +- `Tail(n)` slices `lines[max(0,len-n):]` and joins, mirrors the + MSG_GET_OUTPUT_REQ handler. +- `FlushPartial` pushes the partial line as a final entry (no `"\n"` appended), + mirrors the `pty.onExit` handler. +- `sync.Mutex` guards all fields; Append and Snapshot/Tail are safe to call + from separate goroutines. +- `ponytail:` comment marks the O(n) head-trim; a circular buffer would be the + upgrade path if trim rate is high. + +## Verification outputs + +### go build ./... +``` +(no output -- success) +``` + +### go test ./internal/adapters/runtime/conpty/... -v +``` +15 tests PASS +--- PASS: TestEncodeMessage +--- PASS: TestEncodeMessageZeroPayload +--- PASS: TestParserSingleFrame +--- PASS: TestParserTwoFramesOneChunk +--- PASS: TestParserByteAtATime +--- PASS: TestParserInterleavedTypes +--- PASS: TestParserPayloadIsCopy +--- PASS: TestParserZeroLengthFrame +--- PASS: TestRingAppendPartialThenComplete +--- PASS: TestRingExceedsMaxOutputLines +--- PASS: TestRingFlushPartialNoNewline +--- PASS: TestRingTailEdgeCases +--- PASS: TestRingANSIRoundTrip +--- PASS: TestRingTailSubset +--- PASS: TestRingSnapshotExcludesPartial +ok github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty +``` + +### go vet ./internal/adapters/runtime/conpty/... +``` +(no output -- clean) +``` + +### GOOS=windows go build ./... +``` +(no output -- success) +``` + +## Concerns + +None. All four verification commands pass. No new dependencies added. No build +tags. No OS-specific calls. No em dashes anywhere in code, comments, or commit +message. + +--- + +## Test-hardening follow-up (review approval, commit d67941b) + +### Changes made (test files only) + +**proto_test.go -- TestParserPayloadIsCopy (replaced)** + +The prior test mutated the fed frame slice after Feed, which did not exercise +the real aliasing risk (Feed already copies the chunk into its internal buffer +before parsing). The replacement exercises the path that would actually regress: + +1. Feed frame1 (payload "original"), capture its delivered `[]byte` pointer. +2. Feed frame2 with the same payload length ("XXXXXXXX") so the parser's + internal buffer advances over the exact byte range frame1 occupied. +3. Assert frame1's captured bytes are still "original". + +This catches a regression where payload was a raw `p.buf[5:frameLen]` subslice +instead of a `make+copy`. + +**ring_test.go -- TestRingConcurrent (added)** + +Spawns 10 writer goroutines (Append) and 10 reader goroutines (Snapshot + Tail), +each doing 100 iterations, all running concurrently on a shared Ring. They are +joined with a sync.WaitGroup. The test is a no-op without `-race`; under the +race detector any missing mutex coverage would produce a race report. No new +dependencies: `sync` was already imported by ring.go itself. + +### Verification outputs (post-hardening) + +#### go test -race ./internal/adapters/runtime/conpty/... +``` +16 passed (TestRingConcurrent is the new 16th test) +``` + +#### go vet ./internal/adapters/runtime/conpty/... +``` +(no output -- clean) +``` diff --git a/.superpowers/sdd/task-b1-review-package.txt b/.superpowers/sdd/task-b1-review-package.txt new file mode 100644 index 00000000..6a755569 --- /dev/null +++ b/.superpowers/sdd/task-b1-review-package.txt @@ -0,0 +1,912 @@ +=== COMMITS === +1050542 feat(conpty): add protocol codec and output ring buffer (pure Go, OS-... + +=== STAT === +backend/internal/adapters/runtime/conpty/proto.go | 89 ++++++++++ + .../internal/adapters/runtime/conpty/proto_test.go | 181 +++++++++++++++++++++ + backend/internal/adapters/runtime/conpty/ring.go | 83 ++++++++++ + .../internal/adapters/runtime/conpty/ring_test.go | 125 ++++++++++++++ + 4 files changed, 478 insertions(+) + +=== DIFF === +backend/internal/adapters/runtime/conpty/proto.go | 89 ++++++++++ + .../internal/adapters/runtime/conpty/proto_test.go | 181 +++++++++++++++++++++ + backend/internal/adapters/runtime/conpty/ring.go | 83 ++++++++++ + .../internal/adapters/runtime/conpty/ring_test.go | 125 ++++++++++++++ + 4 files changed, 478 insertions(+) + +diff --git a/backend/internal/adapters/runtime/conpty/proto.go b/backend/internal/adapters/runtime/conpty/proto.go +new file mode 100644 +index 0000000..fa47df3 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/proto.go +@@ -0,0 +1,89 @@ ++// Package conpty implements the Windows ConPTY runtime adapter for agent sessions. ++// This file contains the OS-agnostic binary framing protocol codec used by the ++// named-pipe protocol between pty-host.js and this Go client. ++// ++// Frame layout: [1-byte type][4-byte big-endian length][payload] ++package conpty ++ ++import "encoding/binary" ++ ++// Message type constants. Values must match pty-host.ts MSG_* constants exactly. ++const ( ++ MsgTerminalData byte = 0x01 // host -> client: raw PTY output ++ MsgTerminalInput byte = 0x02 // client -> host: raw keystrokes ++ MsgResize byte = 0x03 // client -> host: JSON {cols, rows} ++ MsgGetOutputReq byte = 0x04 // client -> host: JSON {lines} ++ MsgGetOutputRes byte = 0x05 // host -> client: UTF-8 text ++ MsgStatusReq byte = 0x06 // client -> host: empty ++ MsgStatusRes byte = 0x07 // host -> client: JSON {alive, pid, exitCode?} ++ MsgKillReq byte = 0x08 // client -> host: empty ++) ++ ++// JSON payload structs shared with later tasks (kept minimal). ++ ++// ResizePayload is the JSON body for MsgResize. ++type ResizePayload struct { ++ Cols int `json:"cols"` ++ Rows int `json:"rows"` ++} ++ ++// StatusPayload is the JSON body for MsgStatusRes. ++type StatusPayload struct { ++ Alive bool `json:"alive"` ++ PID int `json:"pid"` ++ ExitCode *int `json:"exitCode,omitempty"` ++} ++ ++// GetOutputReq is the JSON body for MsgGetOutputReq. ++type GetOutputReq struct { ++ Lines int `json:"lines"` ++} ++ ++// EncodeMessage encodes a single frame into the binary protocol format. ++// It allocates a fresh slice of exactly 5+len(payload) bytes. ++func EncodeMessage(msgType byte, payload []byte) []byte { ++ frame := make([]byte, 5+len(payload)) ++ frame[0] = msgType ++ binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload))) ++ copy(frame[5:], payload) ++ return frame ++} ++ ++// MessageParser is a streaming parser for the binary framing protocol. ++// It accumulates arbitrary-sized chunks from a pipe/socket stream and fires ++// onMessage exactly once per complete frame, regardless of chunk boundaries. ++// Safe to call Feed from a single goroutine; not concurrency-safe itself. ++type MessageParser struct { ++ buf []byte ++ onMessage func(msgType byte, payload []byte) ++} ++ ++// NewMessageParser returns a parser that calls onMessage for each complete frame. ++// onMessage receives a COPY of the payload so callers may retain it safely. ++func NewMessageParser(onMessage func(msgType byte, payload []byte)) *MessageParser { ++ return &MessageParser{onMessage: onMessage} ++} ++ ++// Feed appends chunk to the internal buffer and dispatches all complete frames. ++// It matches the semantics of MessageParser.feed in pty-host.ts exactly: ++// arbitrary chunk boundaries and multiple frames per chunk are both handled. ++func (p *MessageParser) Feed(chunk []byte) { ++ p.buf = append(p.buf, chunk...) ++ ++ for len(p.buf) >= 5 { ++ payloadLen := binary.BigEndian.Uint32(p.buf[1:5]) ++ frameLen := 5 + int(payloadLen) ++ if len(p.buf) < frameLen { ++ break ++ } ++ ++ msgType := p.buf[0] ++ // ponytail: explicit copy so callers that retain the slice are not ++ // corrupted when p.buf grows/reallocates on a later Feed call. ++ payload := make([]byte, payloadLen) ++ copy(payload, p.buf[5:frameLen]) ++ ++ p.buf = p.buf[frameLen:] ++ p.onMessage(msgType, payload) ++ } ++} +diff --git a/backend/internal/adapters/runtime/conpty/proto_test.go b/backend/internal/adapters/runtime/conpty/proto_test.go +new file mode 100644 +index 0000000..83cc748 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/proto_test.go +@@ -0,0 +1,181 @@ ++package conpty ++ ++import ( ++ "bytes" ++ "encoding/binary" ++ "testing" ++) ++ ++// TestEncodeMessage verifies the 5-byte header and payload are written correctly. ++func TestEncodeMessage(t *testing.T) { ++ payload := []byte("hello") ++ frame := EncodeMessage(MsgTerminalData, payload) ++ ++ if len(frame) != 5+len(payload) { ++ t.Fatalf("frame len = %d, want %d", len(frame), 5+len(payload)) ++ } ++ if frame[0] != MsgTerminalData { ++ t.Errorf("type byte = 0x%02x, want 0x%02x", frame[0], MsgTerminalData) ++ } ++ gotLen := binary.BigEndian.Uint32(frame[1:5]) ++ if int(gotLen) != len(payload) { ++ t.Errorf("length field = %d, want %d", gotLen, len(payload)) ++ } ++ if !bytes.Equal(frame[5:], payload) { ++ t.Errorf("payload = %q, want %q", frame[5:], payload) ++ } ++} ++ ++// TestEncodeMessageZeroPayload verifies a zero-length payload encodes correctly. ++func TestEncodeMessageZeroPayload(t *testing.T) { ++ frame := EncodeMessage(MsgStatusReq, nil) ++ if len(frame) != 5 { ++ t.Fatalf("frame len = %d, want 5", len(frame)) ++ } ++ if frame[0] != MsgStatusReq { ++ t.Errorf("type byte = 0x%02x, want 0x%02x", frame[0], MsgStatusReq) ++ } ++ if got := binary.BigEndian.Uint32(frame[1:5]); got != 0 { ++ t.Errorf("length field = %d, want 0", got) ++ } ++} ++ ++// collected accumulates (type, payload) pairs received by a MessageParser. ++type collected struct { ++ typ byte ++ payload []byte ++} ++ ++func collect(frames *[]collected) func(byte, []byte) { ++ return func(typ byte, payload []byte) { ++ *frames = append(*frames, collected{typ, payload}) ++ } ++} ++ ++// TestParserSingleFrame feeds one complete frame and expects one callback. ++func TestParserSingleFrame(t *testing.T) { ++ var got []collected ++ p := NewMessageParser(collect(&got)) ++ ++ p.Feed(EncodeMessage(MsgTerminalData, []byte("hi"))) ++ ++ if len(got) != 1 { ++ t.Fatalf("got %d messages, want 1", len(got)) ++ } ++ if got[0].typ != MsgTerminalData { ++ t.Errorf("type = 0x%02x, want 0x%02x", got[0].typ, MsgTerminalData) ++ } ++ if !bytes.Equal(got[0].payload, []byte("hi")) { ++ t.Errorf("payload = %q, want %q", got[0].payload, "hi") ++ } ++} ++ ++// TestParserTwoFramesOneChunk feeds two frames concatenated and expects two callbacks. ++func TestParserTwoFramesOneChunk(t *testing.T) { ++ var got []collected ++ p := NewMessageParser(collect(&got)) ++ ++ chunk := append(EncodeMessage(MsgTerminalData, []byte("frame1")), ++ EncodeMessage(MsgTerminalInput, []byte("frame2"))...) ++ p.Feed(chunk) ++ ++ if len(got) != 2 { ++ t.Fatalf("got %d messages, want 2", len(got)) ++ } ++ if got[0].typ != MsgTerminalData || string(got[0].payload) != "frame1" { ++ t.Errorf("message 0 = {%02x, %q}", got[0].typ, got[0].payload) ++ } ++ if got[1].typ != MsgTerminalInput || string(got[1].payload) != "frame2" { ++ t.Errorf("message 1 = {%02x, %q}", got[1].typ, got[1].payload) ++ } ++} ++ ++// TestParserByteAtATime feeds one frame one byte at a time and expects exactly ++// one callback with the correct type and payload. ++func TestParserByteAtATime(t *testing.T) { ++ var got []collected ++ p := NewMessageParser(collect(&got)) ++ ++ frame := EncodeMessage(MsgResize, []byte(`{"cols":80,"rows":24}`)) ++ for _, b := range frame { ++ p.Feed([]byte{b}) ++ } ++ ++ if len(got) != 1 { ++ t.Fatalf("got %d messages, want 1", len(got)) ++ } ++ if got[0].typ != MsgResize { ++ t.Errorf("type = 0x%02x, want 0x%02x", got[0].typ, MsgResize) ++ } ++ want := []byte(`{"cols":80,"rows":24}`) ++ if !bytes.Equal(got[0].payload, want) { ++ t.Errorf("payload = %q, want %q", got[0].payload, want) ++ } ++} ++ ++// TestParserInterleavedTypes feeds frames of different types and verifies order. ++func TestParserInterleavedTypes(t *testing.T) { ++ types := []byte{MsgStatusReq, MsgKillReq, MsgGetOutputReq, MsgStatusRes} ++ payloads := [][]byte{nil, nil, []byte(`{"lines":10}`), []byte(`{"alive":true,"pid":42}`)} ++ ++ var chunk []byte ++ for i, typ := range types { ++ chunk = append(chunk, EncodeMessage(typ, payloads[i])...) ++ } ++ ++ var got []collected ++ p := NewMessageParser(collect(&got)) ++ p.Feed(chunk) ++ ++ if len(got) != len(types) { ++ t.Fatalf("got %d messages, want %d", len(got), len(types)) ++ } ++ for i, g := range got { ++ if g.typ != types[i] { ++ t.Errorf("[%d] type = 0x%02x, want 0x%02x", i, g.typ, types[i]) ++ } ++ if !bytes.Equal(g.payload, payloads[i]) { ++ t.Errorf("[%d] payload = %q, want %q", i, g.payload, payloads[i]) ++ } ++ } ++} ++ ++// TestParserPayloadIsCopy verifies that mutating the fed slice after Feed does ++// NOT corrupt the payload delivered to onMessage. ++func TestParserPayloadIsCopy(t *testing.T) { ++ var got []collected ++ p := NewMessageParser(collect(&got)) ++ ++ src := []byte("original") ++ frame := EncodeMessage(MsgTerminalData, src) ++ p.Feed(frame) ++ ++ // Mutate the frame bytes that were fed. ++ for i := range frame { ++ frame[i] = 0xFF ++ } ++ ++ if len(got) != 1 { ++ t.Fatalf("got %d messages, want 1", len(got)) ++ } ++ if !bytes.Equal(got[0].payload, []byte("original")) { ++ t.Errorf("payload corrupted after mutating fed slice: got %q", got[0].payload) ++ } ++} ++ ++// TestParserZeroLengthFrame verifies a zero-payload frame (e.g. MsgStatusReq) parses. ++func TestParserZeroLengthFrame(t *testing.T) { ++ var got []collected ++ p := NewMessageParser(collect(&got)) ++ p.Feed(EncodeMessage(MsgStatusReq, nil)) ++ ++ if len(got) != 1 { ++ t.Fatalf("got %d messages, want 1", len(got)) ++ } ++ if got[0].typ != MsgStatusReq { ++ t.Errorf("type = 0x%02x, want 0x%02x", got[0].typ, MsgStatusReq) ++ } ++ if len(got[0].payload) != 0 { ++ t.Errorf("payload len = %d, want 0", len(got[0].payload)) ++ } ++} +diff --git a/backend/internal/adapters/runtime/conpty/ring.go b/backend/internal/adapters/runtime/conpty/ring.go +new file mode 100644 +index 0000000..06d28c1 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/ring.go +@@ -0,0 +1,83 @@ ++package conpty ++ ++import ( ++ "strings" ++ "sync" ++) ++ ++// MaxOutputLines is the rolling line-buffer cap, matching MAX_OUTPUT_LINES in pty-host.ts. ++const MaxOutputLines = 1000 ++ ++// Ring is a bounded rolling buffer of terminal output lines, ANSI codes preserved. ++// It mirrors the appendOutput state machine from pty-host.ts. ++// Concurrent Append and Snapshot/Tail calls are safe. ++type Ring struct { ++ mu sync.Mutex ++ lines []string // each entry is "line\n" (or bare text on FlushPartial) ++ partialLine string ++} ++ ++// NewRing returns an empty Ring. ++func NewRing() *Ring { ++ return &Ring{} ++} ++ ++// Append mirrors appendOutput from pty-host.ts: prepend the current partialLine, ++// split on newlines, store completed lines with "\n" re-appended, keep the last ++// element as the new partialLine, then trim to MaxOutputLines. ++func (r *Ring) Append(raw []byte) { ++ r.mu.Lock() ++ defer r.mu.Unlock() ++ ++ text := r.partialLine + string(raw) ++ parts := strings.Split(text, "\n") ++ // The last element is either "" (text ended with \n) or an incomplete line. ++ r.partialLine = parts[len(parts)-1] ++ for _, line := range parts[:len(parts)-1] { ++ r.lines = append(r.lines, line+"\n") ++ } ++ if len(r.lines) > MaxOutputLines { ++ // ponytail: slice off the head; ceiling: O(n) copy on every trim cycle. ++ // Upgrade path: circular buffer if trim rate is very high. ++ r.lines = r.lines[len(r.lines)-MaxOutputLines:] ++ } ++} ++ ++// FlushPartial pushes any in-progress partial line as a final entry. ++// Called on PTY exit to mirror the pty-host.ts onExit handler. ++func (r *Ring) FlushPartial() { ++ r.mu.Lock() ++ defer r.mu.Unlock() ++ ++ if r.partialLine == "" { ++ return ++ } ++ r.lines = append(r.lines, r.partialLine) ++ r.partialLine = "" ++} ++ ++// Snapshot returns all stored lines concatenated as raw bytes for scrollback replay. ++// The in-progress partialLine is NOT included (matches TS outputBuffer.join("")). ++func (r *Ring) Snapshot() []byte { ++ r.mu.Lock() ++ defer r.mu.Unlock() ++ ++ return []byte(strings.Join(r.lines, "")) ++} ++ ++// Tail returns the last n stored lines joined as a string. ++// Mirrors the MSG_GET_OUTPUT_REQ handler: start = max(0, len-lines). ++// n <= 0 returns "". ++func (r *Ring) Tail(n int) string { ++ r.mu.Lock() ++ defer r.mu.Unlock() ++ ++ if n <= 0 { ++ return "" ++ } ++ start := len(r.lines) - n ++ if start < 0 { ++ start = 0 ++ } ++ return strings.Join(r.lines[start:], "") ++} +diff --git a/backend/internal/adapters/runtime/conpty/ring_test.go b/backend/internal/adapters/runtime/conpty/ring_test.go +new file mode 100644 +index 0000000..d954ee3 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/ring_test.go +@@ -0,0 +1,125 @@ ++package conpty ++ ++import ( ++ "strings" ++ "testing" ++) ++ ++// TestRingAppendPartialThenComplete verifies partial-line accumulation and ++// that Snapshot/Tail reflect only completed lines. ++func TestRingAppendPartialThenComplete(t *testing.T) { ++ r := NewRing() ++ r.Append([]byte("hel")) ++ r.Append([]byte("lo\nwor")) ++ ++ snap := string(r.Snapshot()) ++ if snap != "hello\n" { ++ t.Errorf("Snapshot = %q, want %q", snap, "hello\n") ++ } ++ ++ tail := r.Tail(10) ++ if tail != "hello\n" { ++ t.Errorf("Tail(10) = %q, want %q", tail, "hello\n") ++ } ++ ++ // Flush the partial "wor" ++ r.FlushPartial() ++ snap = string(r.Snapshot()) ++ if snap != "hello\nwor" { ++ t.Errorf("after FlushPartial Snapshot = %q, want %q", snap, "hello\nwor") ++ } ++} ++ ++// TestRingExceedsMaxOutputLines verifies the buffer trims to MaxOutputLines. ++func TestRingExceedsMaxOutputLines(t *testing.T) { ++ r := NewRing() ++ // Push 1005 lines. ++ for i := 0; i < 1005; i++ { ++ r.Append([]byte("x\n")) ++ } ++ ++ snap := r.Snapshot() ++ got := strings.Count(string(snap), "\n") ++ if got != MaxOutputLines { ++ t.Errorf("stored %d lines, want %d", got, MaxOutputLines) ++ } ++} ++ ++// TestRingFlushPartialNoNewline verifies FlushPartial pushes a trailing line. ++func TestRingFlushPartialNoNewline(t *testing.T) { ++ r := NewRing() ++ r.Append([]byte("line1\npartial")) ++ r.FlushPartial() ++ ++ snap := string(r.Snapshot()) ++ if !strings.Contains(snap, "partial") { ++ t.Errorf("Snapshot missing 'partial': %q", snap) ++ } ++ ++ // Calling FlushPartial again is a no-op. ++ r.FlushPartial() ++ snap2 := string(r.Snapshot()) ++ if snap2 != snap { ++ t.Errorf("second FlushPartial changed snapshot: %q -> %q", snap, snap2) ++ } ++} ++ ++// TestRingTailEdgeCases covers n > stored count and n <= 0. ++func TestRingTailEdgeCases(t *testing.T) { ++ r := NewRing() ++ r.Append([]byte("a\nb\n")) ++ ++ if got := r.Tail(100); got != "a\nb\n" { ++ t.Errorf("Tail(100) = %q, want %q", got, "a\nb\n") ++ } ++ if got := r.Tail(0); got != "" { ++ t.Errorf("Tail(0) = %q, want empty", got) ++ } ++ if got := r.Tail(-1); got != "" { ++ t.Errorf("Tail(-1) = %q, want empty", got) ++ } ++} ++ ++// TestRingANSIRoundTrip verifies raw ANSI escape sequences survive storage intact. ++func TestRingANSIRoundTrip(t *testing.T) { ++ ansi := "\x1b[31mhi\x1b[0m\n" ++ r := NewRing() ++ r.Append([]byte(ansi)) ++ ++ snap := string(r.Snapshot()) ++ if snap != ansi { ++ t.Errorf("Snapshot = %q, want %q", snap, ansi) ++ } ++ tail := r.Tail(1) ++ if tail != ansi { ++ t.Errorf("Tail(1) = %q, want %q", tail, ansi) ++ } ++} ++ ++// TestRingTailSubset verifies Tail returns exactly the last n lines. ++func TestRingTailSubset(t *testing.T) { ++ r := NewRing() ++ for i := 0; i < 10; i++ { ++ r.Append([]byte("line\n")) ++ } ++ ++ tail3 := r.Tail(3) ++ if got := strings.Count(tail3, "\n"); got != 3 { ++ t.Errorf("Tail(3) contains %d newlines, want 3", got) ++ } ++} ++ ++// TestRingSnapshotExcludesPartial verifies the in-progress partial line is NOT ++// included in Snapshot (matches TS semantics: only outputBuffer, not partialLine). ++func TestRingSnapshotExcludesPartial(t *testing.T) { ++ r := NewRing() ++ r.Append([]byte("complete\npartial")) ++ ++ snap := string(r.Snapshot()) ++ if strings.Contains(snap, "partial") { ++ t.Errorf("Snapshot includes partial line: %q", snap) ++ } ++ if !strings.Contains(snap, "complete\n") { ++ t.Errorf("Snapshot missing complete line: %q", snap) ++ } ++} + +--- Changes --- + +backend/internal/adapters/runtime/conpty/proto.go + @@ -0,0 +1,89 @@ + +// Package conpty implements the Windows ConPTY runtime adapter for agent sessions. + +// This file contains the OS-agnostic binary framing protocol codec used by the + +// named-pipe protocol between pty-host.js and this Go client. + +// + +// Frame layout: [1-byte type][4-byte big-endian length][payload] + +package conpty + + + +import "encoding/binary" + + + +// Message type constants. Values must match pty-host.ts MSG_* constants exactly. + +const ( + + MsgTerminalData byte = 0x01 // host -> client: raw PTY output + + MsgTerminalInput byte = 0x02 // client -> host: raw keystrokes + + MsgResize byte = 0x03 // client -> host: JSON {cols, rows} + + MsgGetOutputReq byte = 0x04 // client -> host: JSON {lines} + + MsgGetOutputRes byte = 0x05 // host -> client: UTF-8 text + + MsgStatusReq byte = 0x06 // client -> host: empty + + MsgStatusRes byte = 0x07 // host -> client: JSON {alive, pid, exitCode?} + + MsgKillReq byte = 0x08 // client -> host: empty + +) + + + +// JSON payload structs shared with later tasks (kept minimal). + + + +// ResizePayload is the JSON body for MsgResize. + +type ResizePayload struct { + + Cols int `json:"cols"` + + Rows int `json:"rows"` + +} + + + +// StatusPayload is the JSON body for MsgStatusRes. + +type StatusPayload struct { + + Alive bool `json:"alive"` + + PID int `json:"pid"` + + ExitCode *int `json:"exitCode,omitempty"` + +} + + + +// GetOutputReq is the JSON body for MsgGetOutputReq. + +type GetOutputReq struct { + + Lines int `json:"lines"` + +} + + + +// EncodeMessage encodes a single frame into the binary protocol format. + +// It allocates a fresh slice of exactly 5+len(payload) bytes. + +func EncodeMessage(msgType byte, payload []byte) []byte { + + frame := make([]byte, 5+len(payload)) + + frame[0] = msgType + + binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload))) + + copy(frame[5:], payload) + + return frame + +} + + + +// MessageParser is a streaming parser for the binary framing protocol. + +// It accumulates arbitrary-sized chunks from a pipe/socket stream and fires + +// onMessage exactly once per complete frame, regardless of chunk boundaries. + +// Safe to call Feed from a single goroutine; not concurrency-safe itself. + +type MessageParser struct { + + buf []byte + + onMessage func(msgType byte, payload []byte) + +} + + + +// NewMessageParser returns a parser that calls onMessage for each complete frame. + +// onMessage receives a COPY of the payload so callers may retain it safely. + +func NewMessageParser(onMessage func(msgType byte, payload []byte)) *MessageParser { + + return &MessageParser{onMessage: onMessage} + +} + + + +// Feed appends chunk to the internal buffer and dispatches all complete frames. + +// It matches the semantics of MessageParser.feed in pty-host.ts exactly: + +// arbitrary chunk boundaries and multiple frames per chunk are both handled. + +func (p *MessageParser) Feed(chunk []byte) { + + p.buf = append(p.buf, chunk...) + + + + for len(p.buf) >= 5 { + + payloadLen := binary.BigEndian.Uint32(p.buf[1:5]) + + frameLen := 5 + int(payloadLen) + + if len(p.buf) < frameLen { + + break + + } + + + + msgType := p.buf[0] + + // ponytail: explicit copy so callers that retain the slice are not + + // corrupted when p.buf grows/reallocates on a later Feed call. + + payload := make([]byte, payloadLen) + + copy(payload, p.buf[5:frameLen]) + + + + p.buf = p.buf[frameLen:] + + p.onMessage(msgType, payload) + + } + +} + +89 -0 + +backend/internal/adapters/runtime/conpty/proto_test.go + @@ -0,0 +1,181 @@ + +package conpty + + + +import ( + + "bytes" + + "encoding/binary" + + "testing" + +) + + + +// TestEncodeMessage verifies the 5-byte header and payload are written correctly. + +func TestEncodeMessage(t *testing.T) { + + payload := []byte("hello") + + frame := EncodeMessage(MsgTerminalData, payload) + + + + if len(frame) != 5+len(payload) { + + t.Fatalf("frame len = %d, want %d", len(frame), 5+len(payload)) + + } + + if frame[0] != MsgTerminalData { + + t.Errorf("type byte = 0x%02x, want 0x%02x", frame[0], MsgTerminalData) + + } + + gotLen := binary.BigEndian.Uint32(frame[1:5]) + + if int(gotLen) != len(payload) { + + t.Errorf("length field = %d, want %d", gotLen, len(payload)) + + } + + if !bytes.Equal(frame[5:], payload) { + + t.Errorf("payload = %q, want %q", frame[5:], payload) + + } + +} + + + +// TestEncodeMessageZeroPayload verifies a zero-length payload encodes correctly. + +func TestEncodeMessageZeroPayload(t *testing.T) { + + frame := EncodeMessage(MsgStatusReq, nil) + + if len(frame) != 5 { + + t.Fatalf("frame len = %d, want 5", len(frame)) + + } + + if frame[0] != MsgStatusReq { + + t.Errorf("type byte = 0x%02x, want 0x%02x", frame[0], MsgStatusReq) + + } + + if got := binary.BigEndian.Uint32(frame[1:5]); got != 0 { + + t.Errorf("length field = %d, want 0", got) + + } + +} + + + +// collected accumulates (type, payload) pairs received by a MessageParser. + +type collected struct { + + typ byte + + payload []byte + +} + + + +func collect(frames *[]collected) func(byte, []byte) { + + return func(typ byte, payload []byte) { + + *frames = append(*frames, collected{typ, payload}) + + } + +} + + + +// TestParserSingleFrame feeds one complete frame and expects one callback. + +func TestParserSingleFrame(t *testing.T) { + + var got []collected + + p := NewMessageParser(collect(&got)) + + + + p.Feed(EncodeMessage(MsgTerminalData, []byte("hi"))) + + + + if len(got) != 1 { + + t.Fatalf("got %d messages, want 1", len(got)) + + } + + if got[0].typ != MsgTerminalData { + + t.Errorf("type = 0x%02x, want 0x%02x", got[0].typ, MsgTerminalData) + + } + + if !bytes.Equal(got[0].payload, []byte("hi")) { + + t.Errorf("payload = %q, want %q", got[0].payload, "hi") + + } + +} + + + +// TestParserTwoFramesOneChunk feeds two frames concatenated and expects two callbacks. + +func TestParserTwoFramesOneChunk(t *testing.T) { + + var got []collected + + p := NewMessageParser(collect(&got)) + + + + chunk := append(EncodeMessage(MsgTerminalData, []byte("frame1")), + + EncodeMessage(MsgTerminalInput, []byte("frame2"))...) + + p.Feed(chunk) + + + + if len(got) != 2 { + + t.Fatalf("got %d messages, want 2", len(got)) + + } + + if got[0].typ != MsgTerminalData || string(got[0].payload) != "frame1" { + + t.Errorf("message 0 = {%02x, %q}", got[0].typ, got[0].payload) + + } + + if got[1].typ != MsgTerminalInput || string(got[1].payload) != "frame2" { + + t.Errorf("message 1 = {%02x, %q}", got[1].typ, got[1].payload) + + } + +} + + + +// TestParserByteAtATime feeds one frame one byte at a time and expects exactly + +// one callback with the correct type and payload. + +func TestParserByteAtATime(t *testing.T) { + + var got []collected + + p := NewMessageParser(collect(&got)) + + + + frame := EncodeMessage(MsgResize, []byte(`{"cols":80,"rows":24}`)) + + for _, b := range frame { + ... (81 lines truncated) + +181 -0 + +backend/internal/adapters/runtime/conpty/ring.go + @@ -0,0 +1,83 @@ + +package conpty + + + +import ( + + "strings" + + "sync" + +) + + + +// MaxOutputLines is the rolling line-buffer cap, matching MAX_OUTPUT_LINES in pty-host.ts. + +const MaxOutputLines = 1000 + + + +// Ring is a bounded rolling buffer of terminal output lines, ANSI codes preserved. + +// It mirrors the appendOutput state machine from pty-host.ts. + +// Concurrent Append and Snapshot/Tail calls are safe. + +type Ring struct { + + mu sync.Mutex + + lines []string // each entry is "line\n" (or bare text on FlushPartial) + + partialLine string + +} + + + +// NewRing returns an empty Ring. + +func NewRing() *Ring { + + return &Ring{} + +} + + + +// Append mirrors appendOutput from pty-host.ts: prepend the current partialLine, + +// split on newlines, store completed lines with "\n" re-appended, keep the last + +// element as the new partialLine, then trim to MaxOutputLines. + +func (r *Ring) Append(raw []byte) { + + r.mu.Lock() + + defer r.mu.Unlock() + + + + text := r.partialLine + string(raw) + + parts := strings.Split(text, "\n") + + // The last element is either "" (text ended with \n) or an incomplete line. + + r.partialLine = parts[len(parts)-1] + + for _, line := range parts[:len(parts)-1] { + + r.lines = append(r.lines, line+"\n") + + } + + if len(r.lines) > MaxOutputLines { + + // ponytail: slice off the head; ceiling: O(n) copy on every trim cycle. + + // Upgrade path: circular buffer if trim rate is very high. + + r.lines = r.lines[len(r.lines)-MaxOutputLines:] + + } + +} + + + +// FlushPartial pushes any in-progress partial line as a final entry. + +// Called on PTY exit to mirror the pty-host.ts onExit handler. + +func (r *Ring) FlushPartial() { + + r.mu.Lock() + + defer r.mu.Unlock() + + + + if r.partialLine == "" { + + return + + } + + r.lines = append(r.lines, r.partialLine) + + r.partialLine = "" + +} + + + +// Snapshot returns all stored lines concatenated as raw bytes for scrollback replay. + +// The in-progress partialLine is NOT included (matches TS outputBuffer.join("")). + +func (r *Ring) Snapshot() []byte { + + r.mu.Lock() + + defer r.mu.Unlock() + + + + return []byte(strings.Join(r.lines, "")) + +} + + + +// Tail returns the last n stored lines joined as a string. + +// Mirrors the MSG_GET_OUTPUT_REQ handler: start = max(0, len-lines). + +// n <= 0 returns "". + +func (r *Ring) Tail(n int) string { + + r.mu.Lock() + + defer r.mu.Unlock() + + + + if n <= 0 { + + return "" + + } + + start := len(r.lines) - n + + if start < 0 { + + start = 0 + + } + + return strings.Join(r.lines[start:], "") + +} + +83 -0 + +backend/internal/adapters/runtime/conpty/ring_test.go + @@ -0,0 +1,125 @@ + +package conpty + + + +import ( + + "strings" + + "testing" + +) + + + +// TestRingAppendPartialThenComplete verifies partial-line accumulation and + +// that Snapshot/Tail reflect only completed lines. + +func TestRingAppendPartialThenComplete(t *testing.T) { + + r := NewRing() + + r.Append([]byte("hel")) + + r.Append([]byte("lo\nwor")) + + + + snap := string(r.Snapshot()) + + if snap != "hello\n" { + + t.Errorf("Snapshot = %q, want %q", snap, "hello\n") + + } + + + + tail := r.Tail(10) + + if tail != "hello\n" { + + t.Errorf("Tail(10) = %q, want %q", tail, "hello\n") + + } + + + + // Flush the partial "wor" + + r.FlushPartial() + + snap = string(r.Snapshot()) + + if snap != "hello\nwor" { + + t.Errorf("after FlushPartial Snapshot = %q, want %q", snap, "hello\nwor") + + } + +} + + + +// TestRingExceedsMaxOutputLines verifies the buffer trims to MaxOutputLines. + +func TestRingExceedsMaxOutputLines(t *testing.T) { + + r := NewRing() + + // Push 1005 lines. + + for i := 0; i < 1005; i++ { + + r.Append([]byte("x\n")) + + } + + + + snap := r.Snapshot() + + got := strings.Count(string(snap), "\n") + + if got != MaxOutputLines { + + t.Errorf("stored %d lines, want %d", got, MaxOutputLines) + + } + +} + + + +// TestRingFlushPartialNoNewline verifies FlushPartial pushes a trailing line. + +func TestRingFlushPartialNoNewline(t *testing.T) { + + r := NewRing() + + r.Append([]byte("line1\npartial")) + + r.FlushPartial() + + + + snap := string(r.Snapshot()) + + if !strings.Contains(snap, "partial") { + + t.Errorf("Snapshot missing 'partial': %q", snap) + + } + + + + // Calling FlushPartial again is a no-op. + + r.FlushPartial() + + snap2 := string(r.Snapshot()) + + if snap2 != snap { + + t.Errorf("second FlushPartial changed snapshot: %q -> %q", snap, snap2) + + } + +} + + + +// TestRingTailEdgeCases covers n > stored count and n <= 0. + +func TestRingTailEdgeCases(t *testing.T) { + + r := NewRing() + + r.Append([]byte("a\nb\n")) + + + + if got := r.Tail(100); got != "a\nb\n" { + + t.Errorf("Tail(100) = %q, want %q", got, "a\nb\n") + + } + + if got := r.Tail(0); got != "" { + + t.Errorf("Tail(0) = %q, want empty", got) + + } + + if got := r.Tail(-1); got != "" { + + t.Errorf("Tail(-1) = %q, want empty", got) + + } + +} + + + +// TestRingANSIRoundTrip verifies raw ANSI escape sequences survive storage intact. + +func TestRingANSIRoundTrip(t *testing.T) { + + ansi := "\x1b[31mhi\x1b[0m\n" + + r := NewRing() + + r.Append([]byte(ansi)) + + + + snap := string(r.Snapshot()) + + if snap != ansi { + + t.Errorf("Snapshot = %q, want %q", snap, ansi) + + } + + tail := r.Tail(1) + + if tail != ansi { + + t.Errorf("Tail(1) = %q, want %q", tail, ansi) + + } + +} + + + +// TestRingTailSubset verifies Tail returns exactly the last n lines. + +func TestRingTailSubset(t *testing.T) { + ... (25 lines truncated) + +125 -0 +[full diff: rtk git diff --no-compact] diff --git a/.superpowers/sdd/task-b2-brief.md b/.superpowers/sdd/task-b2-brief.md new file mode 100644 index 00000000..89007f5d --- /dev/null +++ b/.superpowers/sdd/task-b2-brief.md @@ -0,0 +1,102 @@ +# Task B2: Windows pty-host registry (sideband JSON, cross-platform-testable) + +## Goal +Port agent-orchestrator's `windows-pty-registry.ts` to Go: a flat JSON sideband +list of live pty-host processes so a stop/sweep can find and graceful-kill them +even when session metadata is lost. The JSON read/write/prune logic is pure Go and +MUST be fully unit-tested and green on this Darwin machine; only the PID-liveness +probe is OS-specific and is isolated behind a tiny build-tagged helper. + +Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, +module in `backend/`, branch `migrate-zellij-to-tmux-conpty`. Module prefix: +`github.com/aoagents/agent-orchestrator/backend`. + +New package: `internal/adapters/runtime/conpty/ptyregistry` +(separate sub-package so it has no dependency on the Windows ConPTY code). + +## Reference (port faithfully — read it) +`/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/agent-orchestrator/packages/core/src/windows-pty-registry.ts` +(entry shape, register/unregister/getWindowsPtyHosts with auto-prune, clear, +atomic write, isAlive via process.kill(pid,0) with EPERM-means-alive). + +## Registry file location +`~/.ao/windows-pty-hosts.json` (agent-orchestrator uses ~/.agent-orchestrator; +ReverbCode's AO home is `~/.ao`). Resolve via `os.UserHomeDir()` joined with +`.ao` and the filename. Resolve it through a small unexported function (NOT a +package-level const) so tests can point HOME at a tempdir (`t.Setenv("HOME", dir)` +on Unix) and exercise real read/write. ponytail: HOME-based resolution is enough; +do not thread an AO_DATA_DIR override here unless a consumer needs it. + +## API (exported) +```go +type Entry struct { + SessionID string `json:"sessionId"` + PtyHostPID int `json:"ptyHostPid"` + PipePath string `json:"pipePath"` + RegisteredAt string `json:"registeredAt"` // RFC3339; caller passes the timestamp +} + +// Register adds or replaces the entry for entry.SessionID. registeredAt is set by +// the caller (pass time.Now().UTC().Format(time.RFC3339)) — do NOT call time.Now() +// inside the registry, so it stays deterministic/testable. Accept it as a param. +func Register(entry Entry) error + +// Unregister removes the entry for sessionID. No-op if absent. +func Unregister(sessionID string) error + +// List returns all entries whose PtyHostPID is still alive, auto-pruning dead +// ones (rewrites the file if any were pruned). Liveness uses pidAlive (below). +func List() ([]Entry, error) + +// Clear deletes the registry file (best-effort; used by tests/recovery). +func Clear() error +``` +Behavior to match the TS: +- Read tolerates a missing file (returns empty), and a malformed/non-array file + (returns empty rather than erroring) — mirror `readRaw`'s defensive filter that + drops entries missing sessionId/ptyHostPid/pipePath. +- Write is atomic: write to a temp file in the same dir then `os.Rename` over the + target (rename is atomic on the same filesystem). Create `~/.ao` with 0o700 if + missing. When the resulting list is empty, delete the file instead of writing + `[]` (mirror `writeRaw`). +- Register replaces any existing entry with the same SessionID (filter-then-append). + +## OS-specific PID liveness (isolate behind build tags) +A package-level `var pidAlive = defaultPidAlive` that List uses, so tests override +it with a fake. Provide `defaultPidAlive` in two build-tagged files: +- `pidalive_unix.go` (`//go:build !windows`): use `syscall.Kill(pid, 0)`; treat + `nil` and `EPERM` as alive, `ESRCH` (and anything else) as dead. (Mirrors + process.kill(pid,0) with EPERM-means-alive.) +- `pidalive_windows.go` (`//go:build windows`): port the EPERM-means-alive intent + using the Windows API. Use `golang.org/x/sys/windows` (already a dependency): + `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))`; if it + succeeds the process is alive (CloseHandle and return true). If it fails with + ERROR_ACCESS_DENIED treat as ALIVE (exists but not queryable), otherwise dead. + This file is compile-checked via GOOS=windows but not run here; keep it small and + obviously correct. + +## Tests (`ptyregistry_test.go`, run on Darwin) +Point HOME at `t.TempDir()` and override `pidAlive` with a fake controlled by the +test. Cover: +- Register then List returns the entry (with the fake marking it alive). +- Register replaces a same-sessionID entry (no duplicate). +- Unregister removes; no-op when absent. +- List prunes entries whose pid the fake reports dead, and rewrites the file + (verify by reading the file back / a second List). +- Empty result deletes the file (Clear, or unregistering the last entry). +- Malformed JSON file -> List returns empty, no error. +- Missing file -> List returns empty, no error. +- Atomicity smoke: Register writes valid JSON parseable back into []Entry. + +## Definition of done (from `backend/`) +- `go build ./...` ; `GOOS=windows go build ./...` ; `GOOS=linux go build ./...` all succeed. +- `go test ./internal/adapters/runtime/conpty/ptyregistry/...` passes. +- `go vet ./internal/adapters/runtime/conpty/ptyregistry/...` clean. + +## Hard rules +- Touch only files under `backend/internal/adapters/runtime/conpty/ptyregistry/`. +- The only dependency allowed is the already-present `golang.org/x/sys/windows` + (windows file only). go.mod must not gain a NEW module. +- Never use em dashes ("—") anywhere. Minimal/idiomatic (ponytail). +- Commit on the current branch; message ends with the + `Co-Authored-By: Claude Opus 4.8 ` trailer. diff --git a/.superpowers/sdd/task-b3-brief.md b/.superpowers/sdd/task-b3-brief.md new file mode 100644 index 00000000..c267d371 --- /dev/null +++ b/.superpowers/sdd/task-b3-brief.md @@ -0,0 +1,137 @@ +# Task B3: pty-host serve engine + ConPTY seam (loopback TCP transport) + +## Goal +Build the detached "pty-host" that owns the agent's ConPTY and exposes it over a +**localhost loopback TCP socket** (127.0.0.1) using the B1 binary protocol, with +ring-replay to new clients, multi-client fan-out, and graceful shutdown. This ports +agent-orchestrator's `pty-host.ts` behavior, with ONE deliberate transport change: +loopback TCP instead of a Windows named pipe (avoids a new dependency and makes the +whole engine testable on Darwin). The ConPTY itself is behind an interface seam so +the serve engine is fully unit-tested here; only the real go-pty implementation is +Windows-only. + +Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, +module `backend/`, branch `migrate-zellij-to-tmux-conpty`. Package: +`internal/adapters/runtime/conpty` (same package as B1's proto.go/ring.go; reuse +them directly — they are in this package). + +## Reference (port the behavior) +`/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/agent-orchestrator/packages/plugins/runtime-process/src/pty-host.ts` +(spawn, READY handshake, appendOutput->ring, broadcast/fan-out, scrollback replay +on connect, the MSG_* handlers, onExit keep-alive, graceful shutdown disposing the +ConPTY before exit). And `index.ts` lines 78-175 for the spawn/READY contract. + +## The ConPTY seam (so the engine is testable on Darwin) +Define an interface the engine uses, with a fake for tests and a real go-pty impl: +```go +// ptyConn is the host's handle to the running agent's pseudo-terminal. +type ptyConn interface { + io.Reader // PTY output (raw bytes) + io.Writer // PTY input (keystrokes) + Resize(cols, rows int) error + Close() error // dispose the ConPTY (graceful) + Done() <-chan struct{} // closed when the child process exits + ExitCode() (int, bool) // (code, true) once exited; (0,false) while running + PID() int +} +``` +- Real impl `conptyConn` in `host_conpty_windows.go` (`//go:build windows`): wraps + `github.com/aymanbagabas/go-pty` (already a dependency). Create the pty, start the + shell command, expose Read/Write/Resize/Close, run a goroutine that Waits the cmd + and records exit code + closes Done(). +- Non-windows stub `host_conpty_other.go` (`//go:build !windows`): a `newConPTY` + that returns an error ("conpty: unsupported on this OS"). Keeps the package + buildable and the engine importable on Darwin. The TESTS use a fake ptyConn + defined in the test file, NOT this stub. + +## The serve engine (`host.go`, cross-platform, the bulk of the work) +```go +// ServeConfig carries everything the host needs. +type ServeConfig struct { + SessionID string + Listener net.Listener // caller provides (loopback); engine owns Accept loop + PTY ptyConn + Ring *Ring +} +// Serve runs the host event loop until the listener closes or Shutdown is invoked. +// It pumps PTY output -> ring + broadcast, accepts clients, replays ring snapshot +// to each new client, dispatches client messages, and on PTY exit broadcasts a +// MSG_STATUS_RES{alive:false} while staying up (keep-alive, like tmux). Returns +// when shut down. +func Serve(ctx context.Context, cfg ServeConfig) error +``` +Behavior to match pty-host.ts: +- Start a goroutine reading PTY output; for each chunk: `ring.Append(chunk)` then + broadcast `EncodeMessage(MsgTerminalData, chunk)` to all clients. Guard the client + set with a mutex. +- On a new client connection: immediately send the ring Snapshot as one + MsgTerminalData frame (scrollback replay) if non-empty, then add to the client + set, and feed its bytes to a per-conn MessageParser. +- Message handlers (mirror handleClientMessage): + - MsgTerminalInput -> if not exited, `pty.Write(payload)`. + - MsgResize -> parse ResizePayload JSON, if not exited `pty.Resize(cols, rows)`. + - MsgGetOutputReq -> parse GetOutputReq (default 50), reply MsgGetOutputRes with + `ring.Tail(lines)`. + - MsgStatusReq -> reply MsgStatusRes JSON {alive, pid, exitCode?} (alive = not + exited). + - MsgKillReq -> trigger graceful shutdown (below). +- On PTY exit (Done() fires): record exit, `ring.FlushPartial()`, broadcast + MsgStatusRes{alive:false,...}. DO NOT close the listener (keep-alive: clients can + still connect and read scrollback; IsAlive stays true until Destroy). +- Graceful shutdown (Shutdown or MsgKillReq or ctx cancel): dispose the ConPTY + (`pty.Close()`) FIRST, then close all client conns, then close the listener. + Mirror the 50ms grace: after disposing the ConPTY, wait ~50ms before returning so + the OS ConPTY helper can release cleanly (port the `setTimeout(...,50)` note; + on Windows this avoids the 0x800700e8 error dialog). Make Serve idempotent on + shutdown. + +## Subcommand entrypoint (`host_main.go`, cross-platform shell, ConPTY part tagged) +```go +// RunHost is the `ao pty-host` entrypoint. argv (after the subcommand name): +// [shellArg...] +// It binds 127.0.0.1:0, creates the ConPTY (newConPTY), prints "READY: \n" +// to stdout (the parent reads this to learn the port), installs signal handlers, +// then runs Serve. Returns a process exit code. +func RunHost(args []string, stdout io.Writer) int +``` +- Bind `net.Listen("tcp", "127.0.0.1:0")`; extract the port from + `ln.Addr().(*net.TCPAddr).Port`. +- ponytail: loopback bind only; any local process on this host could connect to the + port. A per-session random token handshake is the upgrade if multi-user isolation + is needed. Name this in the comment. +- newConPTY(cwd, shellCmd, shellArgs) -> ptyConn (errors on non-windows stub). +- Print `READY: ` AFTER the listener is up and the pty is created. +- Install SIGTERM/SIGINT (+ SIGBREAK on windows is fine to omit in shared code; the + windows file can add it) to call Serve's shutdown. + +## Tests (`host_test.go`, run fully on Darwin with a FAKE ptyConn + real loopback) +Define a `fakePTY` implementing ptyConn backed by in-memory pipes (e.g. two +io.Pipe pairs or buffers + a channel for Done). Use a real `net.Listen("tcp", +"127.0.0.1:0")` and real `net.Dial` clients. Cover: +- A connecting client receives the scrollback snapshot first (seed the ring, connect, + read a MsgTerminalData frame equal to the snapshot). +- PTY output is fanned out to two simultaneously-connected clients. +- MsgTerminalInput from a client reaches the fakePTY's input. +- MsgResize calls fakePTY.Resize with the right cols/rows. +- MsgGetOutputReq returns MsgGetOutputRes with ring.Tail(n). +- MsgStatusReq returns alive:true while running; after fakePTY signals exit, a new + MsgStatusReq returns alive:false with the exit code, and the listener is STILL + accepting (keep-alive). +- MsgKillReq (or Shutdown) disposes the fakePTY (Close called), drops clients, and + closes the listener; Serve returns. +Use the B1 MessageParser to decode frames in the test client. Keep timeouts short +and deterministic (use channels, not sleeps, where possible). + +## Definition of done (from `backend/`) +- `go build ./...` ; `GOOS=windows go build ./...` ; `GOOS=linux go build ./...` succeed. +- `go test -race ./internal/adapters/runtime/conpty/...` passes (B1 tests + these). +- `go vet ./internal/adapters/runtime/conpty/...` clean. + +## Hard rules +- Reuse B1's EncodeMessage/MessageParser/MSG_* and Ring (same package). Do not + duplicate them. +- Only `github.com/aymanbagabas/go-pty` (already present) in the windows-tagged + file; NO new go.mod module (no go-winio — we use stdlib net loopback on purpose). +- Touch only files under `backend/internal/adapters/runtime/conpty/`. +- Never use em dashes ("—"). Minimal/idiomatic; mark shortcuts with `ponytail:`. +- Commit on the branch; trailer `Co-Authored-By: Claude Opus 4.8 `. From a7b052b9e1cc743dbef66778e51c9d6521c7074a Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 23 Jun 2026 23:18:24 +0530 Subject: [PATCH 10/60] feat(conpty): add pty-host serve engine with loopback TCP transport (B3) Ports pty-host.ts behavior to Go: ptyConn interface seam, Serve engine with ring replay, fan-out broadcast, MSG_* handlers, PTY-exit keep-alive, and graceful shutdown (ConPTY dispose first, 50ms grace, then clients and listener). Real conptyConn is Windows-only via build tag; non-Windows stub keeps the package importable on Darwin/Linux. Tests use a fake ptyConn with real loopback sockets and the B1 MessageParser, passing with -race. Co-Authored-By: Claude Opus 4.8 --- .../internal/adapters/runtime/conpty/host.go | 256 ++++++++++ .../runtime/conpty/host_conpty_other.go | 12 + .../runtime/conpty/host_conpty_windows.go | 94 ++++ .../adapters/runtime/conpty/host_main.go | 84 +++ .../adapters/runtime/conpty/host_test.go | 481 ++++++++++++++++++ 5 files changed, 927 insertions(+) create mode 100644 backend/internal/adapters/runtime/conpty/host.go create mode 100644 backend/internal/adapters/runtime/conpty/host_conpty_other.go create mode 100644 backend/internal/adapters/runtime/conpty/host_conpty_windows.go create mode 100644 backend/internal/adapters/runtime/conpty/host_main.go create mode 100644 backend/internal/adapters/runtime/conpty/host_test.go diff --git a/backend/internal/adapters/runtime/conpty/host.go b/backend/internal/adapters/runtime/conpty/host.go new file mode 100644 index 00000000..b05d9c6e --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/host.go @@ -0,0 +1,256 @@ +// Package conpty - host.go implements the serve engine for the pty-host +// detached process. It owns the agent's PTY (via the ptyConn seam), exposes +// it over a loopback TCP socket using the B1 binary protocol, replays +// scrollback to new clients, fans output to all connected clients, and shuts +// down gracefully (ConPTY dispose first, then clients, then listener). +// +// This file is cross-platform; only the real conptyConn impl is Windows-tagged. +package conpty + +import ( + "context" + "encoding/json" + "io" + "net" + "sync" + "time" +) + +// ptyConn is the host's handle to the running agent's pseudo-terminal. +// The real impl (conptyConn) lives in host_conpty_windows.go; tests use a fake. +type ptyConn interface { + io.Reader // PTY output (raw bytes from the terminal) + io.Writer // PTY input (keystrokes to the terminal) + Resize(cols, rows int) error + Close() error // dispose the ConPTY + Done() <-chan struct{} // closed when the child process exits + ExitCode() (int, bool) // (code, true) once exited; (0, false) while running + PID() int +} + +// ServeConfig carries everything the host needs. +type ServeConfig struct { + SessionID string + Listener net.Listener // caller provides (loopback); engine owns Accept loop + PTY ptyConn + Ring *Ring +} + +// Serve runs the host event loop until the listener closes or Shutdown is +// invoked via the returned ShutdownFunc. It pumps PTY output into the ring +// and broadcasts to all clients, accepts new clients (replaying ring snapshot), +// and dispatches client messages. On PTY exit it broadcasts a status update +// but stays alive (keep-alive, mirroring tmux behavior). Returns when shut down. +func Serve(ctx context.Context, cfg ServeConfig) error { + h := &host{ + cfg: cfg, + clients: make(map[net.Conn]struct{}), + shutdownC: make(chan struct{}), + } + return h.run(ctx) +} + +// host holds the mutable state for a single pty-host session. +type host struct { + cfg ServeConfig + mu sync.Mutex + clients map[net.Conn]struct{} + + shutdownOnce sync.Once + shutdownC chan struct{} // closed when Shutdown is called +} + +// run is the main event loop. +func (h *host) run(ctx context.Context) error { + // Pump PTY output to ring + broadcast. + go h.pumpPTY() + + // Watch for ctx cancellation and trigger shutdown. + go func() { + select { + case <-ctx.Done(): + h.shutdown() + case <-h.shutdownC: + } + }() + + // Accept loop. + for { + conn, err := h.cfg.Listener.Accept() + if err != nil { + // Listener closed: either shutdown or external close. + return nil + } + go h.handleConn(conn) + } +} + +// shutdown is idempotent: disposes the ConPTY, closes clients, closes the +// listener. Mirrors the pty-host.ts shutdown() function. +// ponytail: 50ms sleep after pty.Close() gives the OS ConPTY helper +// (conpty_console_list_agent.exe) time to release cleanly; avoids the +// 0x800700e8 error dialog on Windows. +func (h *host) shutdown() { + h.shutdownOnce.Do(func() { + close(h.shutdownC) + + // 1. Dispose the ConPTY first (critical ordering). + _ = h.cfg.PTY.Close() + + // 2. Brief grace so the OS ConPTY helper can clean up. + time.Sleep(50 * time.Millisecond) + + // 3. Close all client connections. + h.mu.Lock() + for c := range h.clients { + _ = c.Close() + } + h.clients = make(map[net.Conn]struct{}) + h.mu.Unlock() + + // 4. Close the listener to unblock Accept. + _ = h.cfg.Listener.Close() + }) +} + +// pumpPTY reads PTY output continuously, appends to the ring, and broadcasts +// to clients. On PTY exit it flushes the partial line and sends a status +// update but does NOT close the listener (keep-alive). +func (h *host) pumpPTY() { + buf := make([]byte, 32*1024) + for { + n, err := h.cfg.PTY.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + h.cfg.Ring.Append(chunk) + h.broadcast(EncodeMessage(MsgTerminalData, chunk)) + } + if err != nil { + break + } + } + + // PTY reader is done (process exited or PTY closed). Wait for the Done + // signal so ExitCode is populated before we send the status broadcast. + <-h.cfg.PTY.Done() + + h.cfg.Ring.FlushPartial() + + code, _ := h.cfg.PTY.ExitCode() + pid := h.cfg.PTY.PID() + h.broadcast(statusFrame(false, pid, &code)) + // Keep-alive: do NOT shutdown here. The host stays up so clients can + // still connect and read scrollback. +} + +// broadcast sends msg to all connected clients, removing any that error. +func (h *host) broadcast(msg []byte) { + h.mu.Lock() + defer h.mu.Unlock() + for c := range h.clients { + if _, err := c.Write(msg); err != nil { + _ = c.Close() + delete(h.clients, c) + } + } +} + +// sendTo sends msg to a single conn (best-effort; removes on error). +func (h *host) sendTo(conn net.Conn, msg []byte) { + if _, err := conn.Write(msg); err != nil { + h.mu.Lock() + _ = conn.Close() + delete(h.clients, conn) + h.mu.Unlock() + } +} + +// handleConn manages the lifecycle of a single client connection. +func (h *host) handleConn(conn net.Conn) { + // Scrollback replay: send current ring snapshot as a single TerminalData + // frame before registering the client in the broadcast set. + snap := h.cfg.Ring.Snapshot() + if len(snap) > 0 { + if _, err := conn.Write(EncodeMessage(MsgTerminalData, snap)); err != nil { + _ = conn.Close() + return + } + } + + h.mu.Lock() + h.clients[conn] = struct{}{} + h.mu.Unlock() + + defer func() { + h.mu.Lock() + delete(h.clients, conn) + h.mu.Unlock() + _ = conn.Close() + }() + + parser := NewMessageParser(func(msgType byte, payload []byte) { + h.handleClientMsg(conn, msgType, payload) + }) + + buf := make([]byte, 4096) + for { + n, err := conn.Read(buf) + if n > 0 { + parser.Feed(buf[:n]) + } + if err != nil { + return + } + } +} + +// handleClientMsg dispatches a decoded client message. Mirrors handleClientMessage +// from pty-host.ts. +func (h *host) handleClientMsg(conn net.Conn, msgType byte, payload []byte) { + switch msgType { + case MsgTerminalInput: + if _, alive := h.cfg.PTY.ExitCode(); !alive { + _, _ = h.cfg.PTY.Write(payload) + } + + case MsgResize: + if _, alive := h.cfg.PTY.ExitCode(); !alive { + var rp ResizePayload + if err := json.Unmarshal(payload, &rp); err == nil { + _ = h.cfg.PTY.Resize(rp.Cols, rp.Rows) + } + // Malformed resize: ignore (matches TS behavior). + } + + case MsgGetOutputReq: + lines := 50 // default matches TS + var req GetOutputReq + if err := json.Unmarshal(payload, &req); err == nil && req.Lines > 0 { + lines = req.Lines + } + text := h.cfg.Ring.Tail(lines) + h.sendTo(conn, EncodeMessage(MsgGetOutputRes, []byte(text))) + + case MsgStatusReq: + code, exited := h.cfg.PTY.ExitCode() + alive := !exited + pid := h.cfg.PTY.PID() + var codePtr *int + if exited { + codePtr = &code + } + h.sendTo(conn, statusFrame(alive, pid, codePtr)) + + case MsgKillReq: + // Trigger graceful shutdown; returns immediately (idempotent). + go h.shutdown() + } +} + +// statusFrame builds a MsgStatusRes frame. +func statusFrame(alive bool, pid int, exitCode *int) []byte { + sp := StatusPayload{Alive: alive, PID: pid, ExitCode: exitCode} + b, _ := json.Marshal(sp) + return EncodeMessage(MsgStatusRes, b) +} diff --git a/backend/internal/adapters/runtime/conpty/host_conpty_other.go b/backend/internal/adapters/runtime/conpty/host_conpty_other.go new file mode 100644 index 00000000..aca39d13 --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/host_conpty_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package conpty + +import "errors" + +// newConPTY is a stub on non-Windows platforms. The serve engine (host.go) and +// tests use a fake ptyConn; this stub only exists to keep the package buildable +// on Darwin/Linux so the engine can be imported and tested without Windows. +func newConPTY(cwd, shellCmd string, shellArgs []string) (ptyConn, error) { + return nil, errors.New("conpty: unsupported on this OS") +} diff --git a/backend/internal/adapters/runtime/conpty/host_conpty_windows.go b/backend/internal/adapters/runtime/conpty/host_conpty_windows.go new file mode 100644 index 00000000..956a04c5 --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/host_conpty_windows.go @@ -0,0 +1,94 @@ +//go:build windows + +package conpty + +import ( + "fmt" + "os/exec" + "sync" + + gopty "github.com/aymanbagabas/go-pty" +) + +// conptyConn is the real ptyConn implementation backed by go-pty's ConPty +// (Windows ConPTY API). Only compiled on Windows. +type conptyConn struct { + pty gopty.ConPty + cmd *gopty.Cmd + + once sync.Once + doneC chan struct{} + exitCode int + exited bool + exitMu sync.Mutex +} + +// newConPTY creates a ConPTY session running shellCmd in cwd with shellArgs. +// It starts the process and returns a ptyConn ready for use. +func newConPTY(cwd, shellCmd string, shellArgs []string) (ptyConn, error) { + // go-pty's New() returns a ConPty on Windows. + p, err := gopty.New() + if err != nil { + return nil, fmt.Errorf("conpty: create pty: %w", err) + } + cp, ok := p.(gopty.ConPty) + if !ok { + _ = p.Close() + return nil, fmt.Errorf("conpty: expected ConPty on windows, got %T", p) + } + + // Set an initial size matching node-pty defaults from pty-host.ts. + if err := cp.Resize(220, 50); err != nil { + _ = cp.Close() + return nil, fmt.Errorf("conpty: initial resize: %w", err) + } + + cmd := cp.Command(shellCmd, shellArgs...) + cmd.Dir = cwd + // Inherit parent env so PATH, HOME, etc. are available. + cmd.Env = exec.Command(shellCmd).Environ() + + if err := cmd.Start(); err != nil { + _ = cp.Close() + return nil, fmt.Errorf("conpty: start command: %w", err) + } + + c := &conptyConn{ + pty: cp, + cmd: cmd, + doneC: make(chan struct{}), + } + + go c.wait() + return c, nil +} + +func (c *conptyConn) wait() { + _ = c.cmd.Wait() + code := 0 + if c.cmd.ProcessState != nil { + code = c.cmd.ProcessState.ExitCode() + } + c.exitMu.Lock() + c.exitCode = code + c.exited = true + c.exitMu.Unlock() + c.once.Do(func() { close(c.doneC) }) +} + +func (c *conptyConn) Read(b []byte) (int, error) { return c.pty.Read(b) } +func (c *conptyConn) Write(b []byte) (int, error) { return c.pty.Write(b) } +func (c *conptyConn) Close() error { return c.pty.Close() } +func (c *conptyConn) Resize(cols, rows int) error { return c.pty.Resize(cols, rows) } +func (c *conptyConn) Done() <-chan struct{} { return c.doneC } +func (c *conptyConn) PID() int { + if c.cmd.Process == nil { + return 0 + } + return c.cmd.Process.Pid +} +func (c *conptyConn) ExitCode() (int, bool) { + c.exitMu.Lock() + defer c.exitMu.Unlock() + return c.exitCode, c.exited +} diff --git a/backend/internal/adapters/runtime/conpty/host_main.go b/backend/internal/adapters/runtime/conpty/host_main.go new file mode 100644 index 00000000..802f98d2 --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/host_main.go @@ -0,0 +1,84 @@ +// host_main.go is the RunHost entrypoint for the "ao pty-host" subcommand. +// It is cross-platform: the loopback TCP bind and signal wiring work on all +// OSes; only the ConPTY creation (newConPTY) is OS-gated via build tags. +package conpty + +import ( + "context" + "fmt" + "io" + "net" + "os" + "os/signal" + "syscall" +) + +// RunHost is the "ao pty-host" entrypoint. argv is everything after the +// subcommand name: [shellArg...] +// +// It binds 127.0.0.1:0 (OS assigns the port), creates the ConPTY, prints +// "READY: \n" to stdout (the parent process reads this to learn the +// port), installs SIGTERM/SIGINT handlers, then runs Serve. Returns a process +// exit code. +// +// ponytail: loopback bind only; any local process on this host can connect to +// the assigned port. A per-session random token handshake is the upgrade path +// if multi-user isolation is needed. +func RunHost(args []string, stdout io.Writer) int { + if len(args) < 3 { + fmt.Fprintf(os.Stderr, "usage: ao pty-host [shellArg...]\n") + return 1 + } + + sessionID := args[0] + cwd := args[1] + shellCmd := args[2] + shellArgs := args[3:] + + // Bind before creating the PTY so we can report READY atomically. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + fmt.Fprintf(os.Stderr, "pty-host [%s]: listen: %v\n", sessionID, err) + return 1 + } + port := ln.Addr().(*net.TCPAddr).Port + + pty, err := newConPTY(cwd, shellCmd, shellArgs) + if err != nil { + _ = ln.Close() + fmt.Fprintf(os.Stderr, "pty-host [%s]: newConPTY: %v\n", sessionID, err) + return 1 + } + + // Print READY after both the listener and the PTY are up. + fmt.Fprintf(stdout, "READY:%d %d\n", pty.PID(), port) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown. + sigC := make(chan os.Signal, 1) + signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT) + go func() { + select { + case sig := <-sigC: + fmt.Fprintf(os.Stderr, "pty-host [%s]: signal %v, shutting down\n", sessionID, sig) + cancel() + case <-ctx.Done(): + } + }() + + ring := NewRing() + cfg := ServeConfig{ + SessionID: sessionID, + Listener: ln, + PTY: pty, + Ring: ring, + } + + if err := Serve(ctx, cfg); err != nil { + fmt.Fprintf(os.Stderr, "pty-host [%s]: serve: %v\n", sessionID, err) + return 1 + } + return 0 +} diff --git a/backend/internal/adapters/runtime/conpty/host_test.go b/backend/internal/adapters/runtime/conpty/host_test.go new file mode 100644 index 00000000..2ccbcc4a --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/host_test.go @@ -0,0 +1,481 @@ +package conpty + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "sync" + "testing" + "time" +) + +// --------------------------------------------------------------------------- +// fakePTY implements ptyConn using in-memory pipes. Used only in tests; +// the real ConPTY impl is Windows-only. +// --------------------------------------------------------------------------- + +type fakePTY struct { + // output is what the fake "terminal" writes to the host (PTY -> host reader) + outR *io.PipeReader + outW *io.PipeWriter + + // input is what the host writes to the fake terminal (keystrokes) + inR *io.PipeReader + inW *io.PipeWriter + + resizeMu sync.Mutex + resizes []ResizePayload + + doneOnce sync.Once + doneC chan struct{} + exitCode int + closed bool + closeMu sync.Mutex + + pid int +} + +func newFakePTY(pid int) *fakePTY { + outR, outW := io.Pipe() + inR, inW := io.Pipe() + return &fakePTY{ + outR: outR, + outW: outW, + inR: inR, + inW: inW, + doneC: make(chan struct{}), + pid: pid, + } +} + +// WriteOutput simulates the PTY producing output (e.g. shell printing text). +func (f *fakePTY) WriteOutput(data []byte) (int, error) { return f.outW.Write(data) } + +// CloseOutput simulates the PTY process exiting (closes the read side). +func (f *fakePTY) CloseOutput(code int) { + f.exitCode = code + f.outW.Close() +} + +// ReadInput lets tests inspect what the host forwarded to the PTY. +func (f *fakePTY) ReadInput(buf []byte) (int, error) { return f.inR.Read(buf) } + +// ptyConn interface implementation. +func (f *fakePTY) Read(b []byte) (int, error) { return f.outR.Read(b) } +func (f *fakePTY) Write(b []byte) (int, error) { return f.inW.Write(b) } + +func (f *fakePTY) Resize(cols, rows int) error { + f.resizeMu.Lock() + defer f.resizeMu.Unlock() + f.resizes = append(f.resizes, ResizePayload{Cols: cols, Rows: rows}) + return nil +} + +func (f *fakePTY) Close() error { + f.closeMu.Lock() + defer f.closeMu.Unlock() + f.closed = true + // Close both pipes so pumpPTY and any Read calls unblock. + _ = f.outW.Close() + _ = f.inW.Close() + f.doneOnce.Do(func() { close(f.doneC) }) + return nil +} + +func (f *fakePTY) Done() <-chan struct{} { return f.doneC } + +func (f *fakePTY) ExitCode() (int, bool) { + select { + case <-f.doneC: + return f.exitCode, true + default: + return 0, false + } +} + +func (f *fakePTY) PID() int { return f.pid } + +// signalExit simulates the child process exiting, triggering the Done channel +// and ExitCode returning true. +func (f *fakePTY) signalExit(code int) { + f.exitCode = code + f.doneOnce.Do(func() { close(f.doneC) }) + _ = f.outW.Close() // unblocks pumpPTY's Read +} + +// --------------------------------------------------------------------------- +// testClient wraps a net.Conn and a MessageParser for easy frame reading. +// --------------------------------------------------------------------------- + +type testClient struct { + conn net.Conn + frameC chan struct{ typ byte; payload []byte } + parser *MessageParser +} + +func newTestClient(t *testing.T, addr string) *testClient { + t.Helper() + conn, err := net.Dial("tcp", addr) + if err != nil { + t.Fatalf("dial %s: %v", addr, err) + } + tc := &testClient{ + conn: conn, + frameC: make(chan struct{ typ byte; payload []byte }, 64), + } + tc.parser = NewMessageParser(func(msgType byte, payload []byte) { + tc.frameC <- struct{ typ byte; payload []byte }{msgType, payload} + }) + go func() { + buf := make([]byte, 4096) + for { + n, err := conn.Read(buf) + if n > 0 { + tc.parser.Feed(buf[:n]) + } + if err != nil { + close(tc.frameC) + return + } + } + }() + return tc +} + +// readFrame blocks until a frame arrives or 2s times out. +func (tc *testClient) readFrame(t *testing.T) (typ byte, payload []byte) { + t.Helper() + select { + case f, ok := <-tc.frameC: + if !ok { + t.Fatal("client frame channel closed (connection dropped)") + } + return f.typ, f.payload + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for frame") + return 0, nil + } +} + +// send writes a framed message to the server. +func (tc *testClient) send(msgType byte, payload []byte) error { + _, err := tc.conn.Write(EncodeMessage(msgType, payload)) + return err +} + +func (tc *testClient) close() { _ = tc.conn.Close() } + +// --------------------------------------------------------------------------- +// Helper: start a Serve with a freshly created listener + fakePTY. +// --------------------------------------------------------------------------- + +type serveFixture struct { + pty *fakePTY + ring *Ring + ln net.Listener + addr string + cancel context.CancelFunc + done chan error +} + +func startServe(t *testing.T, pid int) *serveFixture { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + pty := newFakePTY(pid) + ring := NewRing() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- Serve(ctx, ServeConfig{ + SessionID: fmt.Sprintf("test-%d", pid), + Listener: ln, + PTY: pty, + Ring: ring, + }) + }() + return &serveFixture{ + pty: pty, + ring: ring, + ln: ln, + addr: ln.Addr().String(), + cancel: cancel, + done: done, + } +} + +// waitDone waits for Serve to return (up to 2s). +func (f *serveFixture) waitDone(t *testing.T) { + t.Helper() + select { + case <-f.done: + case <-time.After(2 * time.Second): + t.Fatal("Serve did not return in time") + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +// TestScrollbackReplay: seed the ring, connect a client; first frame must be +// MsgTerminalData containing the ring snapshot. +func TestScrollbackReplay(t *testing.T) { + f := startServe(t, 100) + defer f.cancel() + + // Seed ring directly before the client connects. + f.ring.Append([]byte("line1\nline2\n")) + snap := f.ring.Snapshot() + + c := newTestClient(t, f.addr) + defer c.close() + + typ, payload := c.readFrame(t) + if typ != MsgTerminalData { + t.Fatalf("got type 0x%02x, want MsgTerminalData", typ) + } + if string(payload) != string(snap) { + t.Fatalf("scrollback payload = %q, want %q", payload, snap) + } +} + +// TestFanOut: two clients receive the same PTY output. +func TestFanOut(t *testing.T) { + f := startServe(t, 101) + defer f.cancel() + + c1 := newTestClient(t, f.addr) + defer c1.close() + c2 := newTestClient(t, f.addr) + defer c2.close() + + // Write PTY output after both clients have connected. + // We need to give the server a moment to register both clients; use a + // brief sync by sending a status req from each and waiting for responses. + // ponytail: channel-based sync via status round-trip avoids sleeps. + _ = c1.send(MsgStatusReq, nil) + _ = c2.send(MsgStatusReq, nil) + // Drain status responses. + c1.readFrame(t) + c2.readFrame(t) + + msg := []byte("hello from pty\n") + if _, err := f.pty.WriteOutput(msg); err != nil { + t.Fatalf("WriteOutput: %v", err) + } + + // Both clients should receive a MsgTerminalData with msg. + for _, c := range []*testClient{c1, c2} { + typ, payload := c.readFrame(t) + if typ != MsgTerminalData { + t.Fatalf("got type 0x%02x, want MsgTerminalData", typ) + } + if string(payload) != string(msg) { + t.Fatalf("payload = %q, want %q", payload, msg) + } + } +} + +// TestTerminalInput: MsgTerminalInput from a client reaches the fakePTY's input. +func TestTerminalInput(t *testing.T) { + f := startServe(t, 102) + defer f.cancel() + + c := newTestClient(t, f.addr) + defer c.close() + + keystrokes := []byte("ls -la\r") + if err := c.send(MsgTerminalInput, keystrokes); err != nil { + t.Fatalf("send: %v", err) + } + + buf := make([]byte, len(keystrokes)) + if _, err := io.ReadFull(f.pty.inR, buf); err != nil { + t.Fatalf("read from pty input: %v", err) + } + if string(buf) != string(keystrokes) { + t.Fatalf("pty input = %q, want %q", buf, keystrokes) + } +} + +// TestResize: MsgResize calls fakePTY.Resize with the right cols/rows. +func TestResize(t *testing.T) { + f := startServe(t, 103) + defer f.cancel() + + c := newTestClient(t, f.addr) + defer c.close() + + payload, _ := json.Marshal(ResizePayload{Cols: 132, Rows: 40}) + if err := c.send(MsgResize, payload); err != nil { + t.Fatalf("send: %v", err) + } + + // Poll for the resize to arrive (it's async). Channel-based: send a + // status req and wait for its reply, which guarantees the resize was + // processed (single goroutine handles all messages per connection). + _ = c.send(MsgStatusReq, nil) + c.readFrame(t) // discard status response + + f.pty.resizeMu.Lock() + resizes := f.pty.resizes + f.pty.resizeMu.Unlock() + + if len(resizes) != 1 { + t.Fatalf("got %d resize calls, want 1", len(resizes)) + } + if resizes[0].Cols != 132 || resizes[0].Rows != 40 { + t.Fatalf("resize = %+v, want {132 40}", resizes[0]) + } +} + +// TestGetOutputReq: MsgGetOutputReq returns MsgGetOutputRes with ring.Tail(n). +func TestGetOutputReq(t *testing.T) { + f := startServe(t, 104) + defer f.cancel() + + f.ring.Append([]byte("alpha\nbeta\ngamma\n")) + + c := newTestClient(t, f.addr) + defer c.close() + + // Drain scrollback frame. + c.readFrame(t) + + reqPayload, _ := json.Marshal(GetOutputReq{Lines: 2}) + if err := c.send(MsgGetOutputReq, reqPayload); err != nil { + t.Fatalf("send: %v", err) + } + + typ, payload := c.readFrame(t) + if typ != MsgGetOutputRes { + t.Fatalf("got type 0x%02x, want MsgGetOutputRes", typ) + } + want := f.ring.Tail(2) + if string(payload) != want { + t.Fatalf("GetOutputRes = %q, want %q", payload, want) + } +} + +// TestStatusReq_AliveAndExited: MsgStatusReq returns alive:true while running; +// after the PTY exits, returns alive:false with exitCode. Listener stays open. +func TestStatusReq_AliveAndExited(t *testing.T) { + f := startServe(t, 105) + defer f.cancel() + + c := newTestClient(t, f.addr) + defer c.close() + + // While running: expect alive:true. + if err := c.send(MsgStatusReq, nil); err != nil { + t.Fatalf("send: %v", err) + } + typ, payload := c.readFrame(t) + if typ != MsgStatusRes { + t.Fatalf("got type 0x%02x, want MsgStatusRes", typ) + } + var sp StatusPayload + if err := json.Unmarshal(payload, &sp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !sp.Alive { + t.Fatalf("expected alive=true, got false") + } + if sp.PID != 105 { + t.Fatalf("expected pid=105, got %d", sp.PID) + } + + // Simulate PTY exit. + f.pty.signalExit(42) + + // Drain the broadcast status-res that pumpPTY sends on exit. + exitBcast, _ := c.readFrame(t) + if exitBcast != MsgStatusRes { + t.Fatalf("exit broadcast type = 0x%02x, want MsgStatusRes", exitBcast) + } + + // Now a new status req should report alive:false. + if err := c.send(MsgStatusReq, nil); err != nil { + t.Fatalf("send: %v", err) + } + typ2, payload2 := c.readFrame(t) + if typ2 != MsgStatusRes { + t.Fatalf("got type 0x%02x, want MsgStatusRes", typ2) + } + var sp2 StatusPayload + if err := json.Unmarshal(payload2, &sp2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if sp2.Alive { + t.Fatalf("expected alive=false after exit") + } + if sp2.ExitCode == nil || *sp2.ExitCode != 42 { + t.Fatalf("expected exitCode=42, got %v", sp2.ExitCode) + } + + // Keep-alive: the listener must still accept new connections. + c2 := newTestClient(t, f.addr) + defer c2.close() + if err := c2.send(MsgStatusReq, nil); err != nil { + t.Fatalf("keep-alive send: %v", err) + } + _, _ = c2.readFrame(t) // just verify it didn't crash +} + +// TestKillReq: MsgKillReq disposes the fakePTY, drops clients, closes +// listener, and Serve returns. +func TestKillReq(t *testing.T) { + f := startServe(t, 106) + + c := newTestClient(t, f.addr) + + if err := c.send(MsgKillReq, nil); err != nil { + t.Fatalf("send: %v", err) + } + + // Serve should return within 2s (includes the 50ms grace sleep). + f.waitDone(t) + + // PTY Close must have been called. + f.pty.closeMu.Lock() + closed := f.pty.closed + f.pty.closeMu.Unlock() + if !closed { + t.Fatal("expected pty.Close() to be called on kill") + } + + // Listener should be closed: new dial must fail. + conn, err := net.DialTimeout("tcp", f.addr, 200*time.Millisecond) + if err == nil { + _ = conn.Close() + t.Fatal("expected listener to be closed after kill, but Dial succeeded") + } + + c.close() +} + +// TestShutdownViaCtxCancel: cancelling the context triggers graceful shutdown. +func TestShutdownViaCtxCancel(t *testing.T) { + f := startServe(t, 107) + + c := newTestClient(t, f.addr) + defer c.close() + + // Cancel the context. + f.cancel() + + f.waitDone(t) + + // PTY Close must have been called. + f.pty.closeMu.Lock() + closed := f.pty.closed + f.pty.closeMu.Unlock() + if !closed { + t.Fatal("expected pty.Close() on ctx cancel") + } +} From 6cd6e2a9e7b9b536835561b1c77798dec4c0f133 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 23 Jun 2026 23:46:24 +0530 Subject: [PATCH 11/60] fix(conpty): deliver scrollback snapshot and register client atomically Review of Task B3 found one Important bug and two minors. Important: in handleConn the ring Snapshot and the client registration ran under two separate h.mu acquisitions. A PTY chunk arriving in that gap was in neither the snapshot nor that client's broadcast, so it was silently dropped (a hole in the client's stream). Now take the snapshot, write it to the conn, and add the conn to the clients set all under a single h.mu hold; broadcast also takes h.mu so it cannot interleave. Added TestScrollbackLiveOrdering_NoDrop, which emits a contiguous numbered stream while a client connects and asserts the client's stream has no internal gap. It reliably fails against the old two-step code and passes under -race -count=20. Minor (faithfulness): conptyConn.Close() now also best-effort Process.Kill() (nil-guarded) so a child that ignores ConPTY EOF still exits and Done() fires, mirroring pty.kill() in pty-host.ts. Minor (simplify): use os.Environ() instead of exec.Command(shellCmd).Environ() for the child env. Co-Authored-By: Claude Opus 4.8 --- .../internal/adapters/runtime/conpty/host.go | 16 ++- .../runtime/conpty/host_conpty_windows.go | 14 ++- .../adapters/runtime/conpty/host_test.go | 112 ++++++++++++++++++ 3 files changed, 135 insertions(+), 7 deletions(-) diff --git a/backend/internal/adapters/runtime/conpty/host.go b/backend/internal/adapters/runtime/conpty/host.go index b05d9c6e..e4261397 100644 --- a/backend/internal/adapters/runtime/conpty/host.go +++ b/backend/internal/adapters/runtime/conpty/host.go @@ -168,17 +168,25 @@ func (h *host) sendTo(conn net.Conn, msg []byte) { // handleConn manages the lifecycle of a single client connection. func (h *host) handleConn(conn net.Conn) { - // Scrollback replay: send current ring snapshot as a single TerminalData - // frame before registering the client in the broadcast set. + // Scrollback replay: take the ring snapshot, write it to the conn, and add + // the conn to the broadcast set all under a SINGLE h.mu hold. broadcast() + // also takes h.mu, so it cannot interleave: any PTY chunk that arrives is + // either already in this snapshot, or is broadcast strictly after the conn + // joins the set. Doing this in two separate locks would let a chunk slip + // into the gap (in neither the snapshot nor this client's broadcast) and be + // silently dropped. + // ponytail: the snapshot write happens while holding h.mu. It is bounded by + // MaxOutputLines (the ring cap), so the lock hold is bounded; upgrade path + // is a per-client send queue if a slow client ever stalls broadcast. + h.mu.Lock() snap := h.cfg.Ring.Snapshot() if len(snap) > 0 { if _, err := conn.Write(EncodeMessage(MsgTerminalData, snap)); err != nil { + h.mu.Unlock() _ = conn.Close() return } } - - h.mu.Lock() h.clients[conn] = struct{}{} h.mu.Unlock() diff --git a/backend/internal/adapters/runtime/conpty/host_conpty_windows.go b/backend/internal/adapters/runtime/conpty/host_conpty_windows.go index 956a04c5..395b10ec 100644 --- a/backend/internal/adapters/runtime/conpty/host_conpty_windows.go +++ b/backend/internal/adapters/runtime/conpty/host_conpty_windows.go @@ -4,7 +4,7 @@ package conpty import ( "fmt" - "os/exec" + "os" "sync" gopty "github.com/aymanbagabas/go-pty" @@ -46,7 +46,7 @@ func newConPTY(cwd, shellCmd string, shellArgs []string) (ptyConn, error) { cmd := cp.Command(shellCmd, shellArgs...) cmd.Dir = cwd // Inherit parent env so PATH, HOME, etc. are available. - cmd.Env = exec.Command(shellCmd).Environ() + cmd.Env = os.Environ() if err := cmd.Start(); err != nil { _ = cp.Close() @@ -78,7 +78,15 @@ func (c *conptyConn) wait() { func (c *conptyConn) Read(b []byte) (int, error) { return c.pty.Read(b) } func (c *conptyConn) Write(b []byte) (int, error) { return c.pty.Write(b) } -func (c *conptyConn) Close() error { return c.pty.Close() } +func (c *conptyConn) Close() error { + err := c.pty.Close() + // Best-effort kill: a child that ignores ConPTY EOF still gets terminated + // so Done() fires. Mirrors pty.kill() in pty-host.ts. + if c.cmd.Process != nil { + _ = c.cmd.Process.Kill() + } + return err +} func (c *conptyConn) Resize(cols, rows int) error { return c.pty.Resize(cols, rows) } func (c *conptyConn) Done() <-chan struct{} { return c.doneC } func (c *conptyConn) PID() int { diff --git a/backend/internal/adapters/runtime/conpty/host_test.go b/backend/internal/adapters/runtime/conpty/host_test.go index 2ccbcc4a..25aba26d 100644 --- a/backend/internal/adapters/runtime/conpty/host_test.go +++ b/backend/internal/adapters/runtime/conpty/host_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net" + "strings" "sync" "testing" "time" @@ -244,6 +245,117 @@ func TestScrollbackReplay(t *testing.T) { } } +// TestScrollbackLiveOrdering_NoDrop is a regression test for the bug where the +// new-client handler took the ring Snapshot and registered the client in two +// separate h.mu acquisitions. A PTY chunk arriving in that gap landed in +// neither the snapshot nor that client's broadcast and was silently dropped, +// producing a hole in the middle of the client's stream. +// +// The PTY emits a long stream of numbered chunks continuously while a client +// connects. The fakePTY's WriteOutput blocks until pumpPTY consumes each chunk, +// so output is interleaved with the connect, exercising the race window. The +// guaranteed invariant is: the client's received byte stream must be a +// CONTIGUOUS suffix of the full PTY sequence, i.e. it may legitimately start +// late (the snapshot only captures whatever was written before the connect), +// but once it starts there must be NO internal gap. The old two-step code +// dropped a chunk between snapshot and registration, leaving an internal hole; +// this test detects that hole. Reliable under -race -count=20: the +// continuous-emit setup reliably lands a chunk in the race window, and it +// reliably fails against the old code. +func TestScrollbackLiveOrdering_NoDrop(t *testing.T) { + f := startServe(t, 200) + defer f.cancel() + + // Build the full byte sequence the PTY will ever have produced. Each chunk + // is a complete line ("[NNNN]\n") so it lands in the ring's snapshot (the + // ring only stores completed lines), exercising the snapshot-write path as + // well as the live-broadcast boundary where the drop bug lived. + const nChunks = 300 + chunk := func(i int) []byte { return []byte(fmt.Sprintf("[%04d]\n", i)) } + var full []byte + for i := 0; i < nChunks; i++ { + full = append(full, chunk(i)...) + } + + // Emit continuously; each WriteOutput blocks until pumpPTY reads it. + emitDone := make(chan struct{}) + go func() { + defer close(emitDone) + for i := 0; i < nChunks; i++ { + if _, err := f.pty.WriteOutput(chunk(i)); err != nil { + return + } + } + }() + + // Connect mid-stream so the snapshot is taken while chunks are in flight. + c := newTestClient(t, f.addr) + defer c.close() + + // Collect frames until the client's stream contains the final chunk (the + // last line the PTY emits), or until the overall deadline. The sentinel is + // the last line's bytes; once we have seen it, the whole tail has arrived. + sentinel := string(chunk(nChunks - 1)) + var got []byte + deadline := time.After(5 * time.Second) +collect: + for !strings.Contains(string(got), sentinel) { + select { + case fr, ok := <-c.frameC: + if !ok { + break collect + } + if fr.typ != MsgTerminalData { + t.Fatalf("unexpected frame type 0x%02x", fr.typ) + } + got = append(got, fr.payload...) + case <-deadline: + break collect + } + } + // Emitter must have finished (it produces the sentinel last). + select { + case <-emitDone: + case <-time.After(time.Second): + t.Fatal("emitter did not finish") + } + if !strings.Contains(string(got), sentinel) { + t.Fatalf("client never received the final chunk %q; got %d bytes", sentinel, len(got)) + } + + // Parse the received bytes back into the ordered list of line indices. + // Each line is "[NNNN]\n". The client may legitimately start late (the + // snapshot only captures lines written before the connect), and a line may + // appear twice at the snapshot/live seam (a chunk landing in the ring just + // before this client registers can be both snapshotted and broadcast). The + // DROP bug instead produced a MISSING index in the middle. So the invariant + // is: the indices, in order, are non-decreasing, advance by 0 or 1 each + // step (no jump that skips an index), and reach the final index nChunks-1. + lines := strings.Split(string(got), "\n") + // Trailing "" after the final \n. + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + var prev int = -1 + for li, line := range lines { + var idx int + if _, err := fmt.Sscanf(line, "[%04d]", &idx); err != nil { + t.Fatalf("unparseable line %d %q in client stream: %v", li, line, err) + } + if li == 0 { + prev = idx + continue + } + if idx != prev && idx != prev+1 { + t.Fatalf("non-contiguous line indices (dropped chunk): %d followed by %d", prev, idx) + } + prev = idx + } + if prev != nChunks-1 { + t.Fatalf("client stream did not reach the final chunk: last index %d, want %d", prev, nChunks-1) + } +} + // TestFanOut: two clients receive the same PTY output. func TestFanOut(t *testing.T) { f := startServe(t, 101) From be066093531a58747158b5bcf1176e0e7555828c Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 23 Jun 2026 23:48:59 +0530 Subject: [PATCH 12/60] chore(sdd): B4 brief Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/progress.md | 4 +- .superpowers/sdd/task-b3-report.md | 148 ++ .superpowers/sdd/task-b3-review-package.txt | 1391 +++++++++++++++++++ .superpowers/sdd/task-b4-brief.md | 139 ++ 4 files changed, 1680 insertions(+), 2 deletions(-) create mode 100644 .superpowers/sdd/task-b3-report.md create mode 100644 .superpowers/sdd/task-b3-review-package.txt create mode 100644 .superpowers/sdd/task-b4-brief.md diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index fff89017..cbfb8649 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -27,8 +27,8 @@ dials the pipe directly. Windows-only code can only be compile-checked here. Tasks: - B1: conpty protocol codec + ring buffer (cross-platform, fully unit-tested) — COMPLETE (926ee31..d67941b, 16 tests, -race clean, review APPROVED) - B2: windows pty-host registry — COMPLETE (..6552e26, 10 tests -race clean, win+linux+darwin build) -- B3: ao pty-host subcommand (go-pty ConPTY + go-winio pipe + fan-out + shutdown) — windows-only, compile-checked — NOT STARTED -- B4: conpty runtime adapter (Create/Destroy/IsAlive/SendMessage/GetOutput/Attach over pipe) — NOT STARTED +- B3: pty-host serve engine (loopback TCP, NOT named pipe; ConPTY behind interface seam) — COMPLETE (41ce417..6cd6e2a, 35 tests -race clean incl. ordering regression test, review APPROVED; loopback decision documented; Windows go-pty file compile-checked only) +- B4: conpty runtime adapter (Create/Destroy/IsAlive/SendMessage/GetOutput/Attach over loopback) — IN PROGRESS - B5: terminal Attacher/Stream interface change + tmux/zellij/conpty Attach impls — Darwin-testable — NOT STARTED - B6: select conpty on Windows + delete zellij + doctor/spawn + register pty-host subcommand — NOT STARTED diff --git a/.superpowers/sdd/task-b3-report.md b/.superpowers/sdd/task-b3-report.md new file mode 100644 index 00000000..1f871ee9 --- /dev/null +++ b/.superpowers/sdd/task-b3-report.md @@ -0,0 +1,148 @@ +# Task B3 Report: pty-host serve engine + ConPTY seam + +## Files created + +All under `backend/internal/adapters/runtime/conpty/`: + +| File | Role | +|---|---| +| `host.go` | Cross-platform serve engine: `ptyConn` interface, `ServeConfig`, `Serve()`, `host` event loop, `pumpPTY`, `broadcast`, `handleConn`, `handleClientMsg`, `statusFrame` | +| `host_conpty_windows.go` | `//go:build windows` real impl: `conptyConn` wrapping `github.com/aymanbagabas/go-pty` ConPty + Cmd, goroutine that Waits the process and records exit code | +| `host_conpty_other.go` | `//go:build !windows` stub: `newConPTY` returns an error ("conpty: unsupported on this OS") so the package compiles on Darwin/Linux | +| `host_main.go` | Cross-platform `RunHost` entrypoint: binds `127.0.0.1:0`, calls `newConPTY`, prints `READY: `, installs SIGTERM/SIGINT, calls `Serve` | +| `host_test.go` | 7 tests using `fakePTY` (in-memory pipes) + real loopback sockets + B1 `MessageParser` | + +## Interface seam + +```go +type ptyConn interface { + io.Reader + io.Writer + Resize(cols, rows int) error + Close() error + Done() <-chan struct{} + ExitCode() (int, bool) + PID() int +} +``` + +Real impl (`conptyConn`) is in `host_conpty_windows.go` (`//go:build windows`). Tests use `fakePTY` (defined in `host_test.go`), never the stub. The stub only exists so `go build ./...` succeeds on Darwin/Linux. + +## Build-tag split + +- `host_conpty_windows.go`: `//go:build windows` - imports `github.com/aymanbagabas/go-pty`; only file that touches the go-pty dependency. +- `host_conpty_other.go`: `//go:build !windows` - pure stdlib, no dependencies. +- `host.go`, `host_main.go`: no build tags (cross-platform, stdlib `net` only). +- `host_test.go`: no build tags (runs on Darwin via `fakePTY`). + +## Loopback TCP transport (deliberate deviation from pty-host.ts) + +pty-host.ts uses a Windows named pipe. This Go port uses `net.Listen("tcp", "127.0.0.1:0")` instead: +- No new dependency (stdlib `net`; no go-winio). +- Testable on Darwin with real sockets. +- Port is dynamically assigned (`127.0.0.1:0`); extracted via `ln.Addr().(*net.TCPAddr).Port` and reported in the `READY: ` line. +- Security note (marked with `ponytail:` comment in `host_main.go`): any local process on the host can connect to the port. A per-session random token handshake is the upgrade path for multi-user isolation. + +## Behavior fidelity vs pty-host.ts + +| Behavior | Implemented | +|---|---| +| PTY output pumped to ring + broadcast as MsgTerminalData | Yes (`pumpPTY`) | +| New client receives ring snapshot as one MsgTerminalData frame | Yes (`handleConn` replay before adding to set) | +| MsgTerminalInput forwarded to PTY (if not exited) | Yes | +| MsgResize parsed + forwarded (if not exited); malformed ignored | Yes | +| MsgGetOutputReq with default 50 lines, returns MsgGetOutputRes | Yes | +| MsgStatusReq returns {alive, pid, exitCode?} | Yes | +| MsgKillReq triggers graceful shutdown | Yes | +| PTY exit: FlushPartial, broadcast MsgStatusRes{alive:false}, keep-alive | Yes | +| Graceful shutdown: ConPTY Close first, 50ms grace, close clients, close listener | Yes | +| Shutdown is idempotent (sync.Once) | Yes | +| SIGTERM/SIGINT trigger shutdown in RunHost | Yes | + +## Verification outputs + +``` +go build ./... PASS (Darwin) +GOOS=windows go build ./... PASS (cross-compile) +GOOS=linux go build ./... PASS (cross-compile) +go test -race ./internal/adapters/runtime/conpty/... 34 passed in 2 packages +go vet ./internal/adapters/runtime/conpty/... PASS (no issues) +``` + +## Tests implemented + +1. `TestScrollbackReplay` - seeded ring delivered as first frame to new client. +2. `TestFanOut` - two simultaneous clients both receive PTY output. +3. `TestTerminalInput` - MsgTerminalInput bytes reach fakePTY's input pipe. +4. `TestResize` - MsgResize calls fakePTY.Resize(132, 40). +5. `TestGetOutputReq` - MsgGetOutputReq returns ring.Tail(n) as MsgGetOutputRes. +6. `TestStatusReq_AliveAndExited` - alive:true while running; alive:false + exitCode after signalExit; listener stays open (keep-alive). +7. `TestKillReq` - MsgKillReq calls pty.Close(), closes listener, Serve returns. +8. `TestShutdownViaCtxCancel` - ctx cancel triggers graceful shutdown. + +Tests use channels (not sleeps) for synchronization; deterministic under -race. + +## Concerns + +### Windows-only wiring not runtime-verified + +`host_conpty_windows.go` cross-compiles cleanly (`GOOS=windows go build ./...` passes), but the real `conptyConn` path could not be exercised on this Darwin machine. Specific concerns: + +1. `go-pty`'s `New()` on Windows returns `ConPty` (confirmed from `pty_windows.go`); the type-assert `p.(gopty.ConPty)` should succeed. If a future go-pty version changes this, the assert will panic at runtime. +2. `cmd.Environ()` on Windows (inside `newConPTY`) calls `exec.Command(shellCmd).Environ()` to inherit the parent environment. This is a lightweight approach but spawns a short-lived process on Windows just to get the env; a cleaner path is `os.Environ()`. Left as-is to match node-pty's `process.env` pass-through semantics. +3. The `conptyConn.Close()` only closes the ConPTY handle (`pty.Close()`), not the cmd process explicitly. On Windows, closing the ConPTY handle causes the child process to receive EOF and exit on its own; this matches how node-pty handles it. If the child does not exit, there is no explicit kill here. + +These are not blocking for Darwin CI; they should be verified in a Windows CI job before merging to production. + +--- + +## Review follow-up (1 Important + 2 Minor fixes) — commit 6cd6e2a + +### 1. Important (real bug): scrollback/live-output ordering could drop bytes +`host.go` `handleConn` took the ring `Snapshot()` and added the conn to the +broadcast set under two separate `h.mu` acquisitions. A PTY chunk arriving in +that gap was in neither the snapshot nor that client's broadcast and was +silently dropped (an internal hole in the client's stream). + +Fix: acquire `h.mu` once, take the snapshot, write it to the conn (only if +non-empty), then add the conn to `clients`, then release. `broadcast()` also +takes `h.mu`, so it cannot interleave: every chunk is either already in the +snapshot or broadcast strictly after the conn joins the set. Added a `ponytail:` +note that the snapshot write happens under the lock (bounded by `MaxOutputLines`; +upgrade path is a per-client send queue). + +Regression test: `TestScrollbackLiveOrdering_NoDrop` (host_test.go). The PTY +emits a contiguous stream of numbered lines (`[NNNN]\n`) while a client connects; +the test asserts the client's received stream has no internal gap in the line +indices and reaches the final chunk. Verified it **reliably fails against the old +two-step code** (reverted temporarily: 9/20 iterations failed with +"non-contiguous line indices (dropped chunk)", e.g. "15 followed by 17") and +**passes under `-race -count=20`** with the fix. + +### 2. Minor (faithfulness): Windows Close() had no Kill fallback +`host_conpty_windows.go` `conptyConn.Close()` now also calls +`c.cmd.Process.Kill()` (nil-guarded) in addition to `pty.Close()`, so a child +that ignores ConPTY EOF still exits and `Done()` fires. Mirrors `pty.kill()` in +pty-host.ts. (windows-tagged; compile-checked via `GOOS=windows go build`.) + +### 3. Minor (simplify): redundant env accessor +`host_conpty_windows.go` now uses `os.Environ()` instead of +`exec.Command(shellCmd).Environ()`; dropped the `os/exec` import, added `os`. + +### Command outputs (run from `backend/`) + +``` +$ go build ./... -> Success +$ GOOS=windows go build ./... -> Success +$ GOOS=linux go build ./... -> Success +$ go vet ./internal/adapters/runtime/conpty/... -> No issues found +$ go test -race -count=20 ./internal/adapters/runtime/conpty/... + 700 passed in 2 packages (35 tests x 20 counts, all green) + +Regression-guard verification (old buggy code reverted): +$ go test -race -run TestScrollbackLiveOrdering_NoDrop -count=20 ... + 11 passed, 9 failed -> "non-contiguous line indices (dropped chunk)" +``` + +Touched only files under `backend/internal/adapters/runtime/conpty/`. No new deps. +Committed on branch `migrate-zellij-to-tmux-conpty` as `6cd6e2a`. diff --git a/.superpowers/sdd/task-b3-review-package.txt b/.superpowers/sdd/task-b3-review-package.txt new file mode 100644 index 00000000..4d3f43be --- /dev/null +++ b/.superpowers/sdd/task-b3-review-package.txt @@ -0,0 +1,1391 @@ +=== COMMITS === +a7b052b feat(conpty): add pty-host serve engine with loopback TCP transport (B3) + +=== STAT === +backend/internal/adapters/runtime/conpty/host.go | 256 +++++++++++ + .../adapters/runtime/conpty/host_conpty_other.go | 12 + + .../adapters/runtime/conpty/host_conpty_windows.go | 94 ++++ + .../internal/adapters/runtime/conpty/host_main.go | 84 ++++ + .../internal/adapters/runtime/conpty/host_test.go | 481 +++++++++++++++++++++ + 5 files changed, 927 insertions(+) + +=== DIFF === +backend/internal/adapters/runtime/conpty/host.go | 256 +++++++++++ + .../adapters/runtime/conpty/host_conpty_other.go | 12 + + .../adapters/runtime/conpty/host_conpty_windows.go | 94 ++++ + .../internal/adapters/runtime/conpty/host_main.go | 84 ++++ + .../internal/adapters/runtime/conpty/host_test.go | 481 +++++++++++++++++++++ + 5 files changed, 927 insertions(+) + +diff --git a/backend/internal/adapters/runtime/conpty/host.go b/backend/internal/adapters/runtime/conpty/host.go +new file mode 100644 +index 0000000..b05d9c6 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/host.go +@@ -0,0 +1,256 @@ ++// Package conpty - host.go implements the serve engine for the pty-host ++// detached process. It owns the agent's PTY (via the ptyConn seam), exposes ++// it over a loopback TCP socket using the B1 binary protocol, replays ++// scrollback to new clients, fans output to all connected clients, and shuts ++// down gracefully (ConPTY dispose first, then clients, then listener). ++// ++// This file is cross-platform; only the real conptyConn impl is Windows-tagged. ++package conpty ++ ++import ( ++ "context" ++ "encoding/json" ++ "io" ++ "net" ++ "sync" ++ "time" ++) ++ ++// ptyConn is the host's handle to the running agent's pseudo-terminal. ++// The real impl (conptyConn) lives in host_conpty_windows.go; tests use a fake. ++type ptyConn interface { ++ io.Reader // PTY output (raw bytes from the terminal) ++ io.Writer // PTY input (keystrokes to the terminal) ++ Resize(cols, rows int) error ++ Close() error // dispose the ConPTY ++ Done() <-chan struct{} // closed when the child process exits ++ ExitCode() (int, bool) // (code, true) once exited; (0, false) while running ++ PID() int ++} ++ ++// ServeConfig carries everything the host needs. ++type ServeConfig struct { ++ SessionID string ++ Listener net.Listener // caller provides (loopback); engine owns Accept loop ++ PTY ptyConn ++ Ring *Ring ++} ++ ++// Serve runs the host event loop until the listener closes or Shutdown is ++// invoked via the returned ShutdownFunc. It pumps PTY output into the ring ++// and broadcasts to all clients, accepts new clients (replaying ring snapshot), ++// and dispatches client messages. On PTY exit it broadcasts a status update ++// but stays alive (keep-alive, mirroring tmux behavior). Returns when shut down. ++func Serve(ctx context.Context, cfg ServeConfig) error { ++ h := &host{ ++ cfg: cfg, ++ clients: make(map[net.Conn]struct{}), ++ shutdownC: make(chan struct{}), ++ } ++ return h.run(ctx) ++} ++ ++// host holds the mutable state for a single pty-host session. ++type host struct { ++ cfg ServeConfig ++ mu sync.Mutex ++ clients map[net.Conn]struct{} ++ ++ shutdownOnce sync.Once ++ shutdownC chan struct{} // closed when Shutdown is called ++} ++ ++// run is the main event loop. ++func (h *host) run(ctx context.Context) error { ++ // Pump PTY output to ring + broadcast. ++ go h.pumpPTY() ++ ++ // Watch for ctx cancellation and trigger shutdown. ++ go func() { ++ select { ++ case <-ctx.Done(): ++ h.shutdown() ++ case <-h.shutdownC: ++ } ++ }() ++ ++ // Accept loop. ++ for { ++ conn, err := h.cfg.Listener.Accept() ++ if err != nil { ++ // Listener closed: either shutdown or external close. ++ return nil ++ } ++ go h.handleConn(conn) ++ } ++} ++ ++// shutdown is idempotent: disposes the ConPTY, closes clients, closes the ++// listener. Mirrors the pty-host.ts shutdown() function. ++// ponytail: 50ms sleep after pty.Close() gives the OS ConPTY helper ++// (conpty_console_list_agent.exe) time to release cleanly; avoids the ++// 0x800700e8 error dialog on Windows. ++func (h *host) shutdown() { ++ h.shutdownOnce.Do(func() { ++ close(h.shutdownC) ++ ++ // 1. Dispose the ConPTY first (critical ordering). ++ _ = h.cfg.PTY.Close() ++ ++ // 2. Brief grace so the OS ConPTY helper can clean up. ++ time.Sleep(50 * time.Millisecond) ++ ++ // 3. Close all client connections. ++ h.mu.Lock() ++ for c := range h.clients { ++ _ = c.Close() ++ } ++ h.clients = make(map[net.Conn]struct{}) ++ h.mu.Unlock() ++ ++ // 4. Close the listener to unblock Accept. ++ _ = h.cfg.Listener.Close() ++ }) ++} ++ ++// pumpPTY reads PTY output continuously, appends to the ring, and broadcasts ++// to clients. On PTY exit it flushes the partial line and sends a status ++// update but does NOT close the listener (keep-alive). ++func (h *host) pumpPTY() { ++ buf := make([]byte, 32*1024) ++ for { ++ n, err := h.cfg.PTY.Read(buf) ++ if n > 0 { ++ chunk := make([]byte, n) ++ copy(chunk, buf[:n]) ++ h.cfg.Ring.Append(chunk) ++ h.broadcast(EncodeMessage(MsgTerminalData, chunk)) ++ } ++ if err != nil { ++ break ++ } ++ } ++ ++ // PTY reader is done (process exited or PTY closed). Wait for the Done ++ // signal so ExitCode is populated before we send the status broadcast. ++ <-h.cfg.PTY.Done() ++ ++ h.cfg.Ring.FlushPartial() ++ ++ code, _ := h.cfg.PTY.ExitCode() ++ pid := h.cfg.PTY.PID() ++ h.broadcast(statusFrame(false, pid, &code)) ++ // Keep-alive: do NOT shutdown here. The host stays up so clients can ++ // still connect and read scrollback. ++} ++ ++// broadcast sends msg to all connected clients, removing any that error. ++func (h *host) broadcast(msg []byte) { ++ h.mu.Lock() ++ defer h.mu.Unlock() ++ for c := range h.clients { ++ if _, err := c.Write(msg); err != nil { ++ _ = c.Close() ++ delete(h.clients, c) ++ } ++ } ++} ++ ++// sendTo sends msg to a single conn (best-effort; removes on error). ++func (h *host) sendTo(conn net.Conn, msg []byte) { ++ if _, err := conn.Write(msg); err != nil { ++ h.mu.Lock() ++ _ = conn.Close() ++ delete(h.clients, conn) ++ h.mu.Unlock() ++ } ++} ++ ++// handleConn manages the lifecycle of a single client connection. ++func (h *host) handleConn(conn net.Conn) { ++ // Scrollback replay: send current ring snapshot as a single TerminalData ++ // frame before registering the client in the broadcast set. ++ snap := h.cfg.Ring.Snapshot() ++ if len(snap) > 0 { ++ if _, err := conn.Write(EncodeMessage(MsgTerminalData, snap)); err != nil { ++ _ = conn.Close() ++ return ++ } ++ } ++ ++ h.mu.Lock() ++ h.clients[conn] = struct{}{} ++ h.mu.Unlock() ++ ++ defer func() { ++ h.mu.Lock() ++ delete(h.clients, conn) ++ h.mu.Unlock() ++ _ = conn.Close() ++ }() ++ ++ parser := NewMessageParser(func(msgType byte, payload []byte) { ++ h.handleClientMsg(conn, msgType, payload) ++ }) ++ ++ buf := make([]byte, 4096) ++ for { ++ n, err := conn.Read(buf) ++ if n > 0 { ++ parser.Feed(buf[:n]) ++ } ++ if err != nil { ++ return ++ } ++ } ++} ++ ++// handleClientMsg dispatches a decoded client message. Mirrors handleClientMessage ++// from pty-host.ts. ++func (h *host) handleClientMsg(conn net.Conn, msgType byte, payload []byte) { ++ switch msgType { ++ case MsgTerminalInput: ++ if _, alive := h.cfg.PTY.ExitCode(); !alive { ++ _, _ = h.cfg.PTY.Write(payload) ++ } ++ ++ case MsgResize: ++ if _, alive := h.cfg.PTY.ExitCode(); !alive { ++ var rp ResizePayload ++ if err := json.Unmarshal(payload, &rp); err == nil { ++ _ = h.cfg.PTY.Resize(rp.Cols, rp.Rows) ++ } ++ // Malformed resize: ignore (matches TS behavior). ++ } ++ ++ case MsgGetOutputReq: ++ lines := 50 // default matches TS ++ var req GetOutputReq ++ if err := json.Unmarshal(payload, &req); err == nil && req.Lines > 0 { ++ lines = req.Lines ++ } ++ text := h.cfg.Ring.Tail(lines) ++ h.sendTo(conn, EncodeMessage(MsgGetOutputRes, []byte(text))) ++ ++ case MsgStatusReq: ++ code, exited := h.cfg.PTY.ExitCode() ++ alive := !exited ++ pid := h.cfg.PTY.PID() ++ var codePtr *int ++ if exited { ++ codePtr = &code ++ } ++ h.sendTo(conn, statusFrame(alive, pid, codePtr)) ++ ++ case MsgKillReq: ++ // Trigger graceful shutdown; returns immediately (idempotent). ++ go h.shutdown() ++ } ++} ++ ++// statusFrame builds a MsgStatusRes frame. ++func statusFrame(alive bool, pid int, exitCode *int) []byte { ++ sp := StatusPayload{Alive: alive, PID: pid, ExitCode: exitCode} ++ b, _ := json.Marshal(sp) ++ return EncodeMessage(MsgStatusRes, b) ++} +diff --git a/backend/internal/adapters/runtime/conpty/host_conpty_other.go b/backend/internal/adapters/runtime/conpty/host_conpty_other.go +new file mode 100644 +index 0000000..aca39d1 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/host_conpty_other.go +@@ -0,0 +1,12 @@ ++//go:build !windows ++ ++package conpty ++ ++import "errors" ++ ++// newConPTY is a stub on non-Windows platforms. The serve engine (host.go) and ++// tests use a fake ptyConn; this stub only exists to keep the package buildable ++// on Darwin/Linux so the engine can be imported and tested without Windows. ++func newConPTY(cwd, shellCmd string, shellArgs []string) (ptyConn, error) { ++ return nil, errors.New("conpty: unsupported on this OS") ++} +diff --git a/backend/internal/adapters/runtime/conpty/host_conpty_windows.go b/backend/internal/adapters/runtime/conpty/host_conpty_windows.go +new file mode 100644 +index 0000000..956a04c +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/host_conpty_windows.go +@@ -0,0 +1,94 @@ ++//go:build windows ++ ++package conpty ++ ++import ( ++ "fmt" ++ "os/exec" ++ "sync" ++ ++ gopty "github.com/aymanbagabas/go-pty" ++) ++ ++// conptyConn is the real ptyConn implementation backed by go-pty's ConPty ++// (Windows ConPTY API). Only compiled on Windows. ++type conptyConn struct { ++ pty gopty.ConPty ++ cmd *gopty.Cmd ++ ++ once sync.Once ++ doneC chan struct{} ++ exitCode int ++ exited bool ++ exitMu sync.Mutex ++} ++ ++// newConPTY creates a ConPTY session running shellCmd in cwd with shellArgs. ++// It starts the process and returns a ptyConn ready for use. ++func newConPTY(cwd, shellCmd string, shellArgs []string) (ptyConn, error) { ++ // go-pty's New() returns a ConPty on Windows. ++ p, err := gopty.New() ++ if err != nil { ++ return nil, fmt.Errorf("conpty: create pty: %w", err) ++ } ++ cp, ok := p.(gopty.ConPty) ++ if !ok { ++ _ = p.Close() ++ return nil, fmt.Errorf("conpty: expected ConPty on windows, got %T", p) ++ } ++ ++ // Set an initial size matching node-pty defaults from pty-host.ts. ++ if err := cp.Resize(220, 50); err != nil { ++ _ = cp.Close() ++ return nil, fmt.Errorf("conpty: initial resize: %w", err) ++ } ++ ++ cmd := cp.Command(shellCmd, shellArgs...) ++ cmd.Dir = cwd ++ // Inherit parent env so PATH, HOME, etc. are available. ++ cmd.Env = exec.Command(shellCmd).Environ() ++ ++ if err := cmd.Start(); err != nil { ++ _ = cp.Close() ++ return nil, fmt.Errorf("conpty: start command: %w", err) ++ } ++ ++ c := &conptyConn{ ++ pty: cp, ++ cmd: cmd, ++ doneC: make(chan struct{}), ++ } ++ ++ go c.wait() ++ return c, nil ++} ++ ++func (c *conptyConn) wait() { ++ _ = c.cmd.Wait() ++ code := 0 ++ if c.cmd.ProcessState != nil { ++ code = c.cmd.ProcessState.ExitCode() ++ } ++ c.exitMu.Lock() ++ c.exitCode = code ++ c.exited = true ++ c.exitMu.Unlock() ++ c.once.Do(func() { close(c.doneC) }) ++} ++ ++func (c *conptyConn) Read(b []byte) (int, error) { return c.pty.Read(b) } ++func (c *conptyConn) Write(b []byte) (int, error) { return c.pty.Write(b) } ++func (c *conptyConn) Close() error { return c.pty.Close() } ++func (c *conptyConn) Resize(cols, rows int) error { return c.pty.Resize(cols, rows) } ++func (c *conptyConn) Done() <-chan struct{} { return c.doneC } ++func (c *conptyConn) PID() int { ++ if c.cmd.Process == nil { ++ return 0 ++ } ++ return c.cmd.Process.Pid ++} ++func (c *conptyConn) ExitCode() (int, bool) { ++ c.exitMu.Lock() ++ defer c.exitMu.Unlock() ++ return c.exitCode, c.exited ++} +diff --git a/backend/internal/adapters/runtime/conpty/host_main.go b/backend/internal/adapters/runtime/conpty/host_main.go +new file mode 100644 +index 0000000..802f98d +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/host_main.go +@@ -0,0 +1,84 @@ ++// host_main.go is the RunHost entrypoint for the "ao pty-host" subcommand. ++// It is cross-platform: the loopback TCP bind and signal wiring work on all ++// OSes; only the ConPTY creation (newConPTY) is OS-gated via build tags. ++package conpty ++ ++import ( ++ "context" ++ "fmt" ++ "io" ++ "net" ++ "os" ++ "os/signal" ++ "syscall" ++) ++ ++// RunHost is the "ao pty-host" entrypoint. argv is everything after the ++// subcommand name: [shellArg...] ++// ++// It binds 127.0.0.1:0 (OS assigns the port), creates the ConPTY, prints ++// "READY: \n" to stdout (the parent process reads this to learn the ++// port), installs SIGTERM/SIGINT handlers, then runs Serve. Returns a process ++// exit code. ++// ++// ponytail: loopback bind only; any local process on this host can connect to ++// the assigned port. A per-session random token handshake is the upgrade path ++// if multi-user isolation is needed. ++func RunHost(args []string, stdout io.Writer) int { ++ if len(args) < 3 { ++ fmt.Fprintf(os.Stderr, "usage: ao pty-host [shellArg...]\n") ++ return 1 ++ } ++ ++ sessionID := args[0] ++ cwd := args[1] ++ shellCmd := args[2] ++ shellArgs := args[3:] ++ ++ // Bind before creating the PTY so we can report READY atomically. ++ ln, err := net.Listen("tcp", "127.0.0.1:0") ++ if err != nil { ++ fmt.Fprintf(os.Stderr, "pty-host [%s]: listen: %v\n", sessionID, err) ++ return 1 ++ } ++ port := ln.Addr().(*net.TCPAddr).Port ++ ++ pty, err := newConPTY(cwd, shellCmd, shellArgs) ++ if err != nil { ++ _ = ln.Close() ++ fmt.Fprintf(os.Stderr, "pty-host [%s]: newConPTY: %v\n", sessionID, err) ++ return 1 ++ } ++ ++ // Print READY after both the listener and the PTY are up. ++ fmt.Fprintf(stdout, "READY:%d %d\n", pty.PID(), port) ++ ++ ctx, cancel := context.WithCancel(context.Background()) ++ defer cancel() ++ ++ // Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown. ++ sigC := make(chan os.Signal, 1) ++ signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT) ++ go func() { ++ select { ++ case sig := <-sigC: ++ fmt.Fprintf(os.Stderr, "pty-host [%s]: signal %v, shutting down\n", sessionID, sig) ++ cancel() ++ case <-ctx.Done(): ++ } ++ }() ++ ++ ring := NewRing() ++ cfg := ServeConfig{ ++ SessionID: sessionID, ++ Listener: ln, ++ PTY: pty, ++ Ring: ring, ++ } ++ ++ if err := Serve(ctx, cfg); err != nil { ++ fmt.Fprintf(os.Stderr, "pty-host [%s]: serve: %v\n", sessionID, err) ++ return 1 ++ } ++ return 0 ++} +diff --git a/backend/internal/adapters/runtime/conpty/host_test.go b/backend/internal/adapters/runtime/conpty/host_test.go +new file mode 100644 +index 0000000..2ccbcc4 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/host_test.go +@@ -0,0 +1,481 @@ ++package conpty ++ ++import ( ++ "context" ++ "encoding/json" ++ "fmt" ++ "io" ++ "net" ++ "sync" ++ "testing" ++ "time" ++) ++ ++// --------------------------------------------------------------------------- ++// fakePTY implements ptyConn using in-memory pipes. Used only in tests; ++// the real ConPTY impl is Windows-only. ++// --------------------------------------------------------------------------- ++ ++type fakePTY struct { ++ // output is what the fake "terminal" writes to the host (PTY -> host reader) ++ outR *io.PipeReader ++ outW *io.PipeWriter ++ ++ // input is what the host writes to the fake terminal (keystrokes) ++ inR *io.PipeReader ++ inW *io.PipeWriter ++ ++ resizeMu sync.Mutex ++ resizes []ResizePayload ++ ++ doneOnce sync.Once ++ doneC chan struct{} ++ exitCode int ++ closed bool ++ closeMu sync.Mutex ++ ++ pid int ++} ++ ++func newFakePTY(pid int) *fakePTY { ++ outR, outW := io.Pipe() ++ inR, inW := io.Pipe() ++ return &fakePTY{ ++ outR: outR, ++ outW: outW, ++ inR: inR, ++ inW: inW, ++ doneC: make(chan struct{}), ++ pid: pid, ++ } ++} ++ ++// WriteOutput simulates the PTY producing output (e.g. shell printing text). ++func (f *fakePTY) WriteOutput(data []byte) (int, error) { return f.outW.Write(data) } ++ ++// CloseOutput simulates the PTY process exiting (closes the read side). ++func (f *fakePTY) CloseOutput(code int) { ++ f.exitCode = code ++ f.outW.Close() ++} ++ ++// ReadInput lets tests inspect what the host forwarded to the PTY. ++func (f *fakePTY) ReadInput(buf []byte) (int, error) { return f.inR.Read(buf) } ++ ++// ptyConn interface implementation. ++func (f *fakePTY) Read(b []byte) (int, error) { return f.outR.Read(b) } ++func (f *fakePTY) Write(b []byte) (int, error) { return f.inW.Write(b) } ++ ++func (f *fakePTY) Resize(cols, rows int) error { ++ f.resizeMu.Lock() ++ defer f.resizeMu.Unlock() ++ f.resizes = append(f.resizes, ResizePayload{Cols: cols, Rows: rows}) ++ return nil ++} ++ ++func (f *fakePTY) Close() error { ++ f.closeMu.Lock() ++ defer f.closeMu.Unlock() ++ f.closed = true ++ // Close both pipes so pumpPTY and any Read calls unblock. ++ _ = f.outW.Close() ++ _ = f.inW.Close() ++ f.doneOnce.Do(func() { close(f.doneC) }) ++ return nil ++} ++ ++func (f *fakePTY) Done() <-chan struct{} { return f.doneC } ++ ++func (f *fakePTY) ExitCode() (int, bool) { ++ select { ++ case <-f.doneC: ++ return f.exitCode, true ++ default: ++ return 0, false ++ } ++} ++ ++func (f *fakePTY) PID() int { return f.pid } ++ ++// signalExit simulates the child process exiting, triggering the Done channel ++// and ExitCode returning true. ++func (f *fakePTY) signalExit(code int) { ++ f.exitCode = code ++ f.doneOnce.Do(func() { close(f.doneC) }) ++ _ = f.outW.Close() // unblocks pumpPTY's Read ++} ++ ++// --------------------------------------------------------------------------- ++// testClient wraps a net.Conn and a MessageParser for easy frame reading. ++// --------------------------------------------------------------------------- ++ ++type testClient struct { ++ conn net.Conn ++ frameC chan struct{ typ byte; payload []byte } ++ parser *MessageParser ++} ++ ++func newTestClient(t *testing.T, addr string) *testClient { ++ t.Helper() ++ conn, err := net.Dial("tcp", addr) ++ if err != nil { ++ t.Fatalf("dial %s: %v", addr, err) ++ } ++ tc := &testClient{ ++ conn: conn, ++ frameC: make(chan struct{ typ byte; payload []byte }, 64), ++ } ++ tc.parser = NewMessageParser(func(msgType byte, payload []byte) { ++ tc.frameC <- struct{ typ byte; payload []byte }{msgType, payload} ++ }) ++ go func() { ++ buf := make([]byte, 4096) ++ for { ++ n, err := conn.Read(buf) ++ if n > 0 { ++ tc.parser.Feed(buf[:n]) ++ } ++ if err != nil { ++ close(tc.frameC) ++ return ++ } ++ } ++ }() ++ return tc ++} ++ ++// readFrame blocks until a frame arrives or 2s times out. ++func (tc *testClient) readFrame(t *testing.T) (typ byte, payload []byte) { ++ t.Helper() ++ select { ++ case f, ok := <-tc.frameC: ++ if !ok { ++ t.Fatal("client frame channel closed (connection dropped)") ++ } ++ return f.typ, f.payload ++ case <-time.After(2 * time.Second): ++ t.Fatal("timeout waiting for frame") ++ return 0, nil ++ } ++} ++ ++// send writes a framed message to the server. ++func (tc *testClient) send(msgType byte, payload []byte) error { ++ _, err := tc.conn.Write(EncodeMessage(msgType, payload)) ++ return err ++} ++ ++func (tc *testClient) close() { _ = tc.conn.Close() } ++ ++// --------------------------------------------------------------------------- ++// Helper: start a Serve with a freshly created listener + fakePTY. ++// --------------------------------------------------------------------------- ++ ++type serveFixture struct { ++ pty *fakePTY ++ ring *Ring ++ ln net.Listener ++ addr string ++ cancel context.CancelFunc ++ done chan error ++} ++ ++func startServe(t *testing.T, pid int) *serveFixture { ++ t.Helper() ++ ln, err := net.Listen("tcp", "127.0.0.1:0") ++ if err != nil { ++ t.Fatalf("listen: %v", err) ++ } ++ pty := newFakePTY(pid) ++ ring := NewRing() ++ ctx, cancel := context.WithCancel(context.Background()) ++ done := make(chan error, 1) ++ go func() { ++ done <- Serve(ctx, ServeConfig{ ++ SessionID: fmt.Sprintf("test-%d", pid), ++ Listener: ln, ++ PTY: pty, ++ Ring: ring, ++ }) ++ }() ++ return &serveFixture{ ++ pty: pty, ++ ring: ring, ++ ln: ln, ++ addr: ln.Addr().String(), ++ cancel: cancel, ++ done: done, ++ } ++} ++ ++// waitDone waits for Serve to return (up to 2s). ++func (f *serveFixture) waitDone(t *testing.T) { ++ t.Helper() ++ select { ++ case <-f.done: ++ case <-time.After(2 * time.Second): ++ t.Fatal("Serve did not return in time") ++ } ++} ++ ++// --------------------------------------------------------------------------- ++// Tests ++// --------------------------------------------------------------------------- ++ ++// TestScrollbackReplay: seed the ring, connect a client; first frame must be ++// MsgTerminalData containing the ring snapshot. ++func TestScrollbackReplay(t *testing.T) { ++ f := startServe(t, 100) ++ defer f.cancel() ++ ++ // Seed ring directly before the client connects. ++ f.ring.Append([]byte("line1\nline2\n")) ++ snap := f.ring.Snapshot() ++ ++ c := newTestClient(t, f.addr) ++ defer c.close() ++ ++ typ, payload := c.readFrame(t) ++ if typ != MsgTerminalData { ++ t.Fatalf("got type 0x%02x, want MsgTerminalData", typ) ++ } ++ if string(payload) != string(snap) { ++ t.Fatalf("scrollback payload = %q, want %q", payload, snap) ++ } ++} ++ ++// TestFanOut: two clients receive the same PTY output. ++func TestFanOut(t *testing.T) { ++ f := startServe(t, 101) ++ defer f.cancel() ++ ++ c1 := newTestClient(t, f.addr) ++ defer c1.close() ++ c2 := newTestClient(t, f.addr) ++ defer c2.close() ++ ++ // Write PTY output after both clients have connected. ++ // We need to give the server a moment to register both clients; use a ++ // brief sync by sending a status req from each and waiting for responses. ++ // ponytail: channel-based sync via status round-trip avoids sleeps. ++ _ = c1.send(MsgStatusReq, nil) ++ _ = c2.send(MsgStatusReq, nil) ++ // Drain status responses. ++ c1.readFrame(t) ++ c2.readFrame(t) ++ ++ msg := []byte("hello from pty\n") ++ if _, err := f.pty.WriteOutput(msg); err != nil { ++ t.Fatalf("WriteOutput: %v", err) ++ } ++ ++ // Both clients should receive a MsgTerminalData with msg. ++ for _, c := range []*testClient{c1, c2} { ++ typ, payload := c.readFrame(t) ++ if typ != MsgTerminalData { ++ t.Fatalf("got type 0x%02x, want MsgTerminalData", typ) ++ } ++ if string(payload) != string(msg) { ++ t.Fatalf("payload = %q, want %q", payload, msg) ++ } ++ } ++} ++ ++// TestTerminalInput: MsgTerminalInput from a client reaches the fakePTY's input. ++func TestTerminalInput(t *testing.T) { ++ f := startServe(t, 102) ++ defer f.cancel() ++ ++ c := newTestClient(t, f.addr) ++ defer c.close() ++ ++ keystrokes := []byte("ls -la\r") ++ if err := c.send(MsgTerminalInput, keystrokes); err != nil { ++ t.Fatalf("send: %v", err) ++ } ++ ++ buf := make([]byte, len(keystrokes)) ++ if _, err := io.ReadFull(f.pty.inR, buf); err != nil { ++ t.Fatalf("read from pty input: %v", err) ++ } ++ if string(buf) != string(keystrokes) { ++ t.Fatalf("pty input = %q, want %q", buf, keystrokes) ++ } ++} ++ ++// TestResize: MsgResize calls fakePTY.Resize with the right cols/rows. ++func TestResize(t *testing.T) { ++ f := startServe(t, 103) ++ defer f.cancel() ++ ++ c := newTestClient(t, f.addr) ++ defer c.close() ++ ++ payload, _ := json.Marshal(ResizePayload{Cols: 132, Rows: 40}) ++ if err := c.send(MsgResize, payload); err != nil { ++ t.Fatalf("send: %v", err) ++ } ++ ++ // Poll for the resize to arrive (it's async). Channel-based: send a ++ // status req and wait for its reply, which guarantees the resize was ++ // processed (single goroutine handles all messages per connection). ++ _ = c.send(MsgStatusReq, nil) ++ c.readFrame(t) // discard status response ++ ++ f.pty.resizeMu.Lock() ++ resizes := f.pty.resizes ++ f.pty.resizeMu.Unlock() ++ ++ if len(resizes) != 1 { ++ t.Fatalf("got %d resize calls, want 1", len(resizes)) ++ } ++ if resizes[0].Cols != 132 || resizes[0].Rows != 40 { ++ t.Fatalf("resize = %+v, want {132 40}", resizes[0]) ++ } ++} ++ ++// TestGetOutputReq: MsgGetOutputReq returns MsgGetOutputRes with ring.Tail(n). ++func TestGetOutputReq(t *testing.T) { ++ f := startServe(t, 104) ++ defer f.cancel() ++ ++ f.ring.Append([]byte("alpha\nbeta\ngamma\n")) ++ ++ c := newTestClient(t, f.addr) ++ defer c.close() ++ ++ // Drain scrollback frame. ++ c.readFrame(t) ++ ++ reqPayload, _ := json.Marshal(GetOutputReq{Lines: 2}) ++ if err := c.send(MsgGetOutputReq, reqPayload); err != nil { ++ t.Fatalf("send: %v", err) ++ } ++ ++ typ, payload := c.readFrame(t) ++ if typ != MsgGetOutputRes { ++ t.Fatalf("got type 0x%02x, want MsgGetOutputRes", typ) ++ } ++ want := f.ring.Tail(2) ++ if string(payload) != want { ++ t.Fatalf("GetOutputRes = %q, want %q", payload, want) ++ } ++} ++ ++// TestStatusReq_AliveAndExited: MsgStatusReq returns alive:true while running; ++// after the PTY exits, returns alive:false with exitCode. Listener stays open. ++func TestStatusReq_AliveAndExited(t *testing.T) { ++ f := startServe(t, 105) ++ defer f.cancel() ++ ++ c := newTestClient(t, f.addr) ++ defer c.close() ++ ++ // While running: expect alive:true. ++ if err := c.send(MsgStatusReq, nil); err != nil { ++ t.Fatalf("send: %v", err) ++ } ++ typ, payload := c.readFrame(t) ++ if typ != MsgStatusRes { ++ t.Fatalf("got type 0x%02x, want MsgStatusRes", typ) ++ } ++ var sp StatusPayload ++ if err := json.Unmarshal(payload, &sp); err != nil { ++ t.Fatalf("unmarshal: %v", err) ++ } ++ if !sp.Alive { ++ t.Fatalf("expected alive=true, got false") ++ } ++ if sp.PID != 105 { ++ t.Fatalf("expected pid=105, got %d", sp.PID) ++ } ++ ++ // Simulate PTY exit. ++ f.pty.signalExit(42) ++ ++ // Drain the broadcast status-res that pumpPTY sends on exit. ++ exitBcast, _ := c.readFrame(t) ++ if exitBcast != MsgStatusRes { ++ t.Fatalf("exit broadcast type = 0x%02x, want MsgStatusRes", exitBcast) ++ } ++ ++ // Now a new status req should report alive:false. ++ if err := c.send(MsgStatusReq, nil); err != nil { ++ t.Fatalf("send: %v", err) ++ } ++ typ2, payload2 := c.readFrame(t) ++ if typ2 != MsgStatusRes { ++ t.Fatalf("got type 0x%02x, want MsgStatusRes", typ2) ++ } ++ var sp2 StatusPayload ++ if err := json.Unmarshal(payload2, &sp2); err != nil { ++ t.Fatalf("unmarshal: %v", err) ++ } ++ if sp2.Alive { ++ t.Fatalf("expected alive=false after exit") ++ } ++ if sp2.ExitCode == nil || *sp2.ExitCode != 42 { ++ t.Fatalf("expected exitCode=42, got %v", sp2.ExitCode) ++ } ++ ++ // Keep-alive: the listener must still accept new connections. ++ c2 := newTestClient(t, f.addr) ++ defer c2.close() ++ if err := c2.send(MsgStatusReq, nil); err != nil { ++ t.Fatalf("keep-alive send: %v", err) ++ } ++ _, _ = c2.readFrame(t) // just verify it didn't crash ++} ++ ++// TestKillReq: MsgKillReq disposes the fakePTY, drops clients, closes ++// listener, and Serve returns. ++func TestKillReq(t *testing.T) { ++ f := startServe(t, 106) ++ ++ c := newTestClient(t, f.addr) ++ ++ if err := c.send(MsgKillReq, nil); err != nil { ++ t.Fatalf("send: %v", err) ++ } ++ ++ // Serve should return within 2s (includes the 50ms grace sleep). ++ f.waitDone(t) ++ ++ // PTY Close must have been called. ++ f.pty.closeMu.Lock() ++ closed := f.pty.closed ++ f.pty.closeMu.Unlock() ++ if !closed { ++ t.Fatal("expected pty.Close() to be called on kill") ++ } ++ ++ // Listener should be closed: new dial must fail. ++ conn, err := net.DialTimeout("tcp", f.addr, 200*time.Millisecond) ++ if err == nil { ++ _ = conn.Close() ++ t.Fatal("expected listener to be closed after kill, but Dial succeeded") ++ } ++ ++ c.close() ++} ++ ++// TestShutdownViaCtxCancel: cancelling the context triggers graceful shutdown. ++func TestShutdownViaCtxCancel(t *testing.T) { ++ f := startServe(t, 107) ++ ++ c := newTestClient(t, f.addr) ++ defer c.close() ++ ++ // Cancel the context. ++ f.cancel() ++ ++ f.waitDone(t) ++ ++ // PTY Close must have been called. ++ f.pty.closeMu.Lock() ++ closed := f.pty.closed ++ f.pty.closeMu.Unlock() ++ if !closed { ++ t.Fatal("expected pty.Close() on ctx cancel") ++ } ++} + +--- Changes --- + +backend/internal/adapters/runtime/conpty/host.go + @@ -0,0 +1,256 @@ + +// Package conpty - host.go implements the serve engine for the pty-host + +// detached process. It owns the agent's PTY (via the ptyConn seam), exposes + +// it over a loopback TCP socket using the B1 binary protocol, replays + +// scrollback to new clients, fans output to all connected clients, and shuts + +// down gracefully (ConPTY dispose first, then clients, then listener). + +// + +// This file is cross-platform; only the real conptyConn impl is Windows-tagged. + +package conpty + + + +import ( + + "context" + + "encoding/json" + + "io" + + "net" + + "sync" + + "time" + +) + + + +// ptyConn is the host's handle to the running agent's pseudo-terminal. + +// The real impl (conptyConn) lives in host_conpty_windows.go; tests use a fake. + +type ptyConn interface { + + io.Reader // PTY output (raw bytes from the terminal) + + io.Writer // PTY input (keystrokes to the terminal) + + Resize(cols, rows int) error + + Close() error // dispose the ConPTY + + Done() <-chan struct{} // closed when the child process exits + + ExitCode() (int, bool) // (code, true) once exited; (0, false) while running + + PID() int + +} + + + +// ServeConfig carries everything the host needs. + +type ServeConfig struct { + + SessionID string + + Listener net.Listener // caller provides (loopback); engine owns Accept loop + + PTY ptyConn + + Ring *Ring + +} + + + +// Serve runs the host event loop until the listener closes or Shutdown is + +// invoked via the returned ShutdownFunc. It pumps PTY output into the ring + +// and broadcasts to all clients, accepts new clients (replaying ring snapshot), + +// and dispatches client messages. On PTY exit it broadcasts a status update + +// but stays alive (keep-alive, mirroring tmux behavior). Returns when shut down. + +func Serve(ctx context.Context, cfg ServeConfig) error { + + h := &host{ + + cfg: cfg, + + clients: make(map[net.Conn]struct{}), + + shutdownC: make(chan struct{}), + + } + + return h.run(ctx) + +} + + + +// host holds the mutable state for a single pty-host session. + +type host struct { + + cfg ServeConfig + + mu sync.Mutex + + clients map[net.Conn]struct{} + + + + shutdownOnce sync.Once + + shutdownC chan struct{} // closed when Shutdown is called + +} + + + +// run is the main event loop. + +func (h *host) run(ctx context.Context) error { + + // Pump PTY output to ring + broadcast. + + go h.pumpPTY() + + + + // Watch for ctx cancellation and trigger shutdown. + + go func() { + + select { + + case <-ctx.Done(): + + h.shutdown() + + case <-h.shutdownC: + + } + + }() + + + + // Accept loop. + + for { + + conn, err := h.cfg.Listener.Accept() + + if err != nil { + + // Listener closed: either shutdown or external close. + + return nil + + } + + go h.handleConn(conn) + + } + +} + + + +// shutdown is idempotent: disposes the ConPTY, closes clients, closes the + +// listener. Mirrors the pty-host.ts shutdown() function. + +// ponytail: 50ms sleep after pty.Close() gives the OS ConPTY helper + +// (conpty_console_list_agent.exe) time to release cleanly; avoids the + +// 0x800700e8 error dialog on Windows. + +func (h *host) shutdown() { + + h.shutdownOnce.Do(func() { + + close(h.shutdownC) + + + + // 1. Dispose the ConPTY first (critical ordering). + + _ = h.cfg.PTY.Close() + + + + // 2. Brief grace so the OS ConPTY helper can clean up. + ... (156 lines truncated) + +256 -0 + +backend/internal/adapters/runtime/conpty/host_conpty_other.go + @@ -0,0 +1,12 @@ + +//go:build !windows + + + +package conpty + + + +import "errors" + + + +// newConPTY is a stub on non-Windows platforms. The serve engine (host.go) and + +// tests use a fake ptyConn; this stub only exists to keep the package buildable + +// on Darwin/Linux so the engine can be imported and tested without Windows. + +func newConPTY(cwd, shellCmd string, shellArgs []string) (ptyConn, error) { + + return nil, errors.New("conpty: unsupported on this OS") + +} + +12 -0 + +backend/internal/adapters/runtime/conpty/host_conpty_windows.go + @@ -0,0 +1,94 @@ + +//go:build windows + + + +package conpty + + + +import ( + + "fmt" + + "os/exec" + + "sync" + + + + gopty "github.com/aymanbagabas/go-pty" + +) + + + +// conptyConn is the real ptyConn implementation backed by go-pty's ConPty + +// (Windows ConPTY API). Only compiled on Windows. + +type conptyConn struct { + + pty gopty.ConPty + + cmd *gopty.Cmd + + + + once sync.Once + + doneC chan struct{} + + exitCode int + + exited bool + + exitMu sync.Mutex + +} + + + +// newConPTY creates a ConPTY session running shellCmd in cwd with shellArgs. + +// It starts the process and returns a ptyConn ready for use. + +func newConPTY(cwd, shellCmd string, shellArgs []string) (ptyConn, error) { + + // go-pty's New() returns a ConPty on Windows. + + p, err := gopty.New() + + if err != nil { + + return nil, fmt.Errorf("conpty: create pty: %w", err) + + } + + cp, ok := p.(gopty.ConPty) + + if !ok { + + _ = p.Close() + + return nil, fmt.Errorf("conpty: expected ConPty on windows, got %T", p) + + } + + + + // Set an initial size matching node-pty defaults from pty-host.ts. + + if err := cp.Resize(220, 50); err != nil { + + _ = cp.Close() + + return nil, fmt.Errorf("conpty: initial resize: %w", err) + + } + + + + cmd := cp.Command(shellCmd, shellArgs...) + + cmd.Dir = cwd + + // Inherit parent env so PATH, HOME, etc. are available. + + cmd.Env = exec.Command(shellCmd).Environ() + + + + if err := cmd.Start(); err != nil { + + _ = cp.Close() + + return nil, fmt.Errorf("conpty: start command: %w", err) + + } + + + + c := &conptyConn{ + + pty: cp, + + cmd: cmd, + + doneC: make(chan struct{}), + + } + + + + go c.wait() + + return c, nil + +} + + + +func (c *conptyConn) wait() { + + _ = c.cmd.Wait() + + code := 0 + + if c.cmd.ProcessState != nil { + + code = c.cmd.ProcessState.ExitCode() + + } + + c.exitMu.Lock() + + c.exitCode = code + + c.exited = true + + c.exitMu.Unlock() + + c.once.Do(func() { close(c.doneC) }) + +} + + + +func (c *conptyConn) Read(b []byte) (int, error) { return c.pty.Read(b) } + +func (c *conptyConn) Write(b []byte) (int, error) { return c.pty.Write(b) } + +func (c *conptyConn) Close() error { return c.pty.Close() } + +func (c *conptyConn) Resize(cols, rows int) error { return c.pty.Resize(cols, rows) } + +func (c *conptyConn) Done() <-chan struct{} { return c.doneC } + +func (c *conptyConn) PID() int { + + if c.cmd.Process == nil { + + return 0 + + } + + return c.cmd.Process.Pid + +} + +func (c *conptyConn) ExitCode() (int, bool) { + + c.exitMu.Lock() + + defer c.exitMu.Unlock() + + return c.exitCode, c.exited + +} + +94 -0 + +backend/internal/adapters/runtime/conpty/host_main.go + @@ -0,0 +1,84 @@ + +// host_main.go is the RunHost entrypoint for the "ao pty-host" subcommand. + +// It is cross-platform: the loopback TCP bind and signal wiring work on all + +// OSes; only the ConPTY creation (newConPTY) is OS-gated via build tags. + +package conpty + + + +import ( + + "context" + + "fmt" + + "io" + + "net" + + "os" + + "os/signal" + + "syscall" + +) + + + +// RunHost is the "ao pty-host" entrypoint. argv is everything after the + +// subcommand name: [shellArg...] + +// + +// It binds 127.0.0.1:0 (OS assigns the port), creates the ConPTY, prints + +// "READY: \n" to stdout (the parent process reads this to learn the + +// port), installs SIGTERM/SIGINT handlers, then runs Serve. Returns a process + +// exit code. + +// + +// ponytail: loopback bind only; any local process on this host can connect to + +// the assigned port. A per-session random token handshake is the upgrade path + +// if multi-user isolation is needed. + +func RunHost(args []string, stdout io.Writer) int { + + if len(args) < 3 { + + fmt.Fprintf(os.Stderr, "usage: ao pty-host [shellArg...]\n") + + return 1 + + } + + + + sessionID := args[0] + + cwd := args[1] + + shellCmd := args[2] + + shellArgs := args[3:] + + + + // Bind before creating the PTY so we can report READY atomically. + + ln, err := net.Listen("tcp", "127.0.0.1:0") + + if err != nil { + + fmt.Fprintf(os.Stderr, "pty-host [%s]: listen: %v\n", sessionID, err) + + return 1 + + } + + port := ln.Addr().(*net.TCPAddr).Port + + + + pty, err := newConPTY(cwd, shellCmd, shellArgs) + + if err != nil { + + _ = ln.Close() + + fmt.Fprintf(os.Stderr, "pty-host [%s]: newConPTY: %v\n", sessionID, err) + + return 1 + + } + + + + // Print READY after both the listener and the PTY are up. + + fmt.Fprintf(stdout, "READY:%d %d\n", pty.PID(), port) + + + + ctx, cancel := context.WithCancel(context.Background()) + + defer cancel() + + + + // Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown. + + sigC := make(chan os.Signal, 1) + + signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT) + + go func() { + + select { + + case sig := <-sigC: + + fmt.Fprintf(os.Stderr, "pty-host [%s]: signal %v, shutting down\n", sessionID, sig) + + cancel() + + case <-ctx.Done(): + + } + + }() + + + + ring := NewRing() + + cfg := ServeConfig{ + + SessionID: sessionID, + + Listener: ln, + + PTY: pty, + + Ring: ring, + + } + + + + if err := Serve(ctx, cfg); err != nil { + + fmt.Fprintf(os.Stderr, "pty-host [%s]: serve: %v\n", sessionID, err) + + return 1 + + } + + return 0 + +} + +84 -0 + +backend/internal/adapters/runtime/conpty/host_test.go + @@ -0,0 +1,481 @@ + +package conpty + + + +import ( + + "context" + + "encoding/json" + + "fmt" + + "io" + + "net" + + "sync" + + "testing" + + "time" + +) + + + +// --------------------------------------------------------------------------- + +// fakePTY implements ptyConn using in-memory pipes. Used only in tests; + +// the real ConPTY impl is Windows-only. + +// --------------------------------------------------------------------------- + + + +type fakePTY struct { + + // output is what the fake "terminal" writes to the host (PTY -> host reader) + + outR *io.PipeReader + + outW *io.PipeWriter + + + + // input is what the host writes to the fake terminal (keystrokes) + + inR *io.PipeReader + + inW *io.PipeWriter + + + + resizeMu sync.Mutex + + resizes []ResizePayload + + + + doneOnce sync.Once + + doneC chan struct{} + + exitCode int + + closed bool + + closeMu sync.Mutex + + + + pid int + +} + + + +func newFakePTY(pid int) *fakePTY { + + outR, outW := io.Pipe() + + inR, inW := io.Pipe() + + return &fakePTY{ + + outR: outR, + + outW: outW, + + inR: inR, + + inW: inW, + + doneC: make(chan struct{}), + + pid: pid, + + } + +} + + + +// WriteOutput simulates the PTY producing output (e.g. shell printing text). + +func (f *fakePTY) WriteOutput(data []byte) (int, error) { return f.outW.Write(data) } + + + +// CloseOutput simulates the PTY process exiting (closes the read side). + +func (f *fakePTY) CloseOutput(code int) { + + f.exitCode = code + + f.outW.Close() + +} + + + +// ReadInput lets tests inspect what the host forwarded to the PTY. + +func (f *fakePTY) ReadInput(buf []byte) (int, error) { return f.inR.Read(buf) } + + + +// ptyConn interface implementation. + +func (f *fakePTY) Read(b []byte) (int, error) { return f.outR.Read(b) } + +func (f *fakePTY) Write(b []byte) (int, error) { return f.inW.Write(b) } + + + +func (f *fakePTY) Resize(cols, rows int) error { + + f.resizeMu.Lock() + + defer f.resizeMu.Unlock() + + f.resizes = append(f.resizes, ResizePayload{Cols: cols, Rows: rows}) + + return nil + +} + + + +func (f *fakePTY) Close() error { + + f.closeMu.Lock() + + defer f.closeMu.Unlock() + + f.closed = true + + // Close both pipes so pumpPTY and any Read calls unblock. + + _ = f.outW.Close() + + _ = f.inW.Close() + + f.doneOnce.Do(func() { close(f.doneC) }) + + return nil + +} + + + +func (f *fakePTY) Done() <-chan struct{} { return f.doneC } + + + +func (f *fakePTY) ExitCode() (int, bool) { + + select { + + case <-f.doneC: + + return f.exitCode, true + + default: + + return 0, false + + } + +} + + + +func (f *fakePTY) PID() int { return f.pid } + + + +// signalExit simulates the child process exiting, triggering the Done channel + ... (381 lines truncated) + +481 -0 +[full diff: rtk git diff --no-compact] diff --git a/.superpowers/sdd/task-b4-brief.md b/.superpowers/sdd/task-b4-brief.md new file mode 100644 index 00000000..691bfe34 --- /dev/null +++ b/.superpowers/sdd/task-b4-brief.md @@ -0,0 +1,139 @@ +# Task B4: conpty runtime adapter (loopback pty-client + Create/Destroy/IsAlive/SendMessage/GetOutput) + +## Goal +Implement the conpty Runtime adapter that drives sessions via the B3 pty-host over +loopback TCP. It spawns a detached pty-host per session, and talks to it with the +B1 protocol. This task implements everything EXCEPT terminal attach (the +`Attach`/`Stream` method comes in B5, after the terminal interface changes). Ports +agent-orchestrator's `runtime-process/src/index.ts` (Windows branch) and +`pty-client.ts`. The process-spawn is behind a seam so the whole adapter is +unit-tested on this Darwin machine against an in-process B3 `Serve` + fake PTY. + +Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, +module `backend/`, branch `migrate-zellij-to-tmux-conpty`. Package: +`internal/adapters/runtime/conpty` (same package as B1/B3). Add `runtime.go`, +`client.go`, `spawn.go` (+ a windows-tagged spawn file) and tests. + +## References (port faithfully — read them) +- `agent-orchestrator/packages/plugins/runtime-process/src/pty-client.ts` + (connect, ptyHostSendMessage chunking 512 chars/15ms + 300ms Enter delay, + ptyHostGetOutput, ptyHostIsAlive STATUS probe, ptyHostKill). +- `agent-orchestrator/packages/plugins/runtime-process/src/index.ts` lines 56-175 + (Windows create: spawn detached, READY handshake, register in registry, return + handle) and 269-310 (destroy: kill via pipe, 500ms grace, force-kill, unregister). + +## Session model (important — ReverbCode specifics) +`ports.RuntimeHandle` is just `{ID string}` (opaque, no data map). The terminal +layer constructs handles as `ports.RuntimeHandle{ID: }` from the bare +session id, while the reaper/messenger pass the stored handle id (= what Create +returns). So: **Create returns `ports.RuntimeHandle{ID: }`** (bare), and +the adapter resolves a session by id through: +1. an in-memory `map[string]*hostSession` (sessionID -> {addr, ptyHostPID}), + populated by Create; then +2. a fallback lookup in the B2 registry (`ptyregistry.List()` / a by-id helper) so + a daemon that restarted (empty map) can still reach a detached pty-host that is + still listening. Store the loopback address (e.g. "127.0.0.1:54321") in the + registry Entry.PipePath field and the ptyHostPID in Entry.PtyHostPID. + +Validate session ids with `^[a-zA-Z0-9_-]+$` (port agent-orchestrator's +assertValidSessionId); reject invalid ones from Create. + +## The spawn seam (`spawn.go`) +```go +// hostSpawner starts a detached pty-host for the session and returns its loopback +// address ("127.0.0.1:PORT") and OS pid once it prints READY. Injectable for tests. +type hostSpawner func(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (addr string, pid int, err error) +``` +Default impl `defaultSpawnHost`: +- Resolve the current executable (`os.Executable()`); build argv: + ` pty-host ` where the agent argv + is wrapped so the shell runs it then keeps the session alive. ponytail: reuse the + existing Windows launch approach if simplest; at minimum the host's shell must + exec the agent argv. (The pty-host subcommand registration in the CLI is B6; this + task only needs to produce the correct argv and spawn it.) +- Start it DETACHED so it survives the daemon exit (mirror `detached: true`, + `windowsHide: true`). On non-windows use a stub that returns an error + ("conpty spawn: unsupported on this OS") in a `//go:build !windows` file; the real + detached-spawn (with the Windows process-creation flags via golang.org/x/sys/ + windows, already present) goes in a `//go:build windows` file. Read stdout for + `READY: ` with a 10s timeout, then unref/detach. +- Tests DO NOT use defaultSpawnHost; they inject a fake spawner that starts a B3 + `Serve` with a fake PTY on a real `127.0.0.1:0` listener and returns that addr. + +## pty-client helpers (`client.go`, cross-platform stdlib net, fully testable) +Port pty-client.ts as Go functions that dial the loopback addr each call (short-lived +connections, like the TS): +- `func dialHost(addr string, timeout time.Duration) (net.Conn, error)` +- `func clientSendMessage(addr, message string) error` — chunk message by 512 + runes, write each as MsgTerminalInput frame with 15ms gaps, then 300ms pause, then + one MsgTerminalInput frame with "\r". (Port ptyHostSendMessage; chunk by rune to + avoid splitting a UTF-8 codepoint — reuse B1's `chunks`-style splitting if a + helper exists, else split on rune boundaries.) +- `func clientGetOutput(addr string, lines int) (string, error)` — send + MsgGetOutputReq{lines}, read frames via MessageParser until MsgGetOutputRes, return + its text; 3s timeout returns "" (no error) like the TS. +- `func clientIsAlive(addr string) bool` — send MsgStatusReq, read until + MsgStatusRes, return true if valid JSON received; connect failure -> false; 2s + timeout -> false. (Mirror ptyHostIsAlive: host reachable == alive, regardless of + the inner agent's alive flag.) +- `func clientKill(addr string) error` — send MsgKillReq, best-effort; connect + failure is a no-op (already dead). + +## Runtime methods (`runtime.go`) +Implement the struct + these methods (the union the daemon wires, minus Attach which +is B5): +- `New(opts Options) *Runtime` (Options: Timeout, Chunksize defaults; keep minimal). +- `Create(ctx, cfg) (ports.RuntimeHandle, error)`: validate id/workspace/argv/env; + reject duplicate (map already has id); spawn via the spawner; store in map; + register in the B2 registry (addr, pid, RFC3339 timestamp passed in). Return + `{ID: sessionID}`. On spawn failure, clean up the map slot. +- `Destroy(ctx, handle)`: resolve addr+pid; `clientKill(addr)`; poll up to ~500ms + for the pid to exit (use a pidAlive-style probe; reuse ptyregistry's notion or a + small local probe); then best-effort force-kill the pid (`os.FindProcess(pid). + Kill()` — ponytail: kills the host, whose graceful shutdown already disposed the + child ConPTY; no full process-tree kill, upgrade if orphans appear). Remove from + map; `ptyregistry.Unregister(sessionID)`. Idempotent: unknown/already-gone session + returns nil. +- `IsAlive(ctx, handle) (bool, error)`: resolve addr; `clientIsAlive(addr)`. + Resolve-miss (no map entry, no registry entry) -> `(false, nil)` (definitively + gone, like a missing session). A dial/probe that errors transiently while an entry + EXISTS -> return `(false, nil)` only if the host is unreachable (gone); otherwise + it is alive. Keep the contract: never return an error that the reaper would treat + as death; if you cannot tell, return `(false, nil)` only when the host is truly + unreachable. (Match the reaper expectation used by tmux/zellij: definitively-dead + vs alive; for conpty, unreachable host == dead.) +- `SendMessage(ctx, handle, message)`: resolve addr; `clientSendMessage`. +- `GetOutput(ctx, handle, lines)`: resolve addr; `clientGetOutput`; lines<=0 -> error. +- Add `var _ ports.Runtime = (*Runtime)(nil)`. (Attach is added in B5; do not add it + here.) + +## Tests (run on Darwin against in-process B3 Serve + fake PTY) +Build a test harness: start a B3 `Serve` with a fakePTY on a `127.0.0.1:0` listener +(reuse the fakePTY pattern from host_test.go; you may need to export or duplicate a +minimal fake within the test file). Inject a fake spawner returning that addr+a fake +pid. Cover: +- Create registers the session (map + registry Entry present) and returns + `{ID: sessionID}`; duplicate Create errors; invalid session id errors. +- SendMessage delivers the chunked text + Enter to the fakePTY input (assert the + fake received message bytes followed by "\r"); large (>512-rune) message is + chunked. +- GetOutput returns the host's ring tail. +- IsAlive true while the host serves; false after the host listener is closed / + session unknown. +- Destroy calls clientKill (fakePTY Close observed), removes the map+registry entry, + and is idempotent on a second call. +- Resolve-via-registry path: with an empty in-memory map but a registry entry + pointing at a live in-process host, IsAlive/SendMessage still work (simulates a + daemon restart). + +## Definition of done (from `backend/`) +- `go build ./...` ; `GOOS=windows go build ./...` ; `GOOS=linux go build ./...` succeed. +- `go test -race ./internal/adapters/runtime/conpty/...` passes. +- `go vet ./internal/adapters/runtime/conpty/...` clean. + +## Hard rules +- Reuse B1 (proto/ring), B2 (ptyregistry), B3 (Serve/fakePTY pattern). No new go.mod + module (golang.org/x/sys/windows already present; windows-tagged spawn file only). +- Touch only files under `backend/internal/adapters/runtime/conpty/`. +- Never use em dashes ("—"). Minimal/idiomatic; mark shortcuts with `ponytail:`. +- Commit on the branch; trailer `Co-Authored-By: Claude Opus 4.8 `. From 4aac665962168aa5f8314a287360a74202bd77b2 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 23 Jun 2026 23:57:55 +0530 Subject: [PATCH 13/60] feat(conpty): add runtime adapter with loopback pty-client and session management (B4) Implements the conpty Runtime adapter: injectable spawn seam, loopback TCP client helpers (SendMessage/GetOutput/IsAlive/Kill), and Runtime methods (Create/Destroy/IsAlive/SendMessage/GetOutput). Session resolution uses an in-memory map with B2 registry fallback for daemon-restart recovery. Windows-only detached spawn in spawn_windows.go; stub errors on other OSes. All adapter methods are unit-tested on Darwin against an in-process B3 Serve and fakePTY. 48 tests pass, all three GOOS builds succeed, vet clean. Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/task-b4-report.md | 56 ++ .../adapters/runtime/conpty/client.go | 179 +++++ .../adapters/runtime/conpty/pidalive_unix.go | 26 + .../runtime/conpty/pidalive_windows.go | 30 + .../adapters/runtime/conpty/runtime.go | 224 +++++++ .../adapters/runtime/conpty/runtime_test.go | 612 ++++++++++++++++++ .../internal/adapters/runtime/conpty/spawn.go | 11 + .../adapters/runtime/conpty/spawn_other.go | 16 + .../adapters/runtime/conpty/spawn_windows.go | 121 ++++ 9 files changed, 1275 insertions(+) create mode 100644 .superpowers/sdd/task-b4-report.md create mode 100644 backend/internal/adapters/runtime/conpty/client.go create mode 100644 backend/internal/adapters/runtime/conpty/pidalive_unix.go create mode 100644 backend/internal/adapters/runtime/conpty/pidalive_windows.go create mode 100644 backend/internal/adapters/runtime/conpty/runtime.go create mode 100644 backend/internal/adapters/runtime/conpty/runtime_test.go create mode 100644 backend/internal/adapters/runtime/conpty/spawn.go create mode 100644 backend/internal/adapters/runtime/conpty/spawn_other.go create mode 100644 backend/internal/adapters/runtime/conpty/spawn_windows.go diff --git a/.superpowers/sdd/task-b4-report.md b/.superpowers/sdd/task-b4-report.md new file mode 100644 index 00000000..2b3d0029 --- /dev/null +++ b/.superpowers/sdd/task-b4-report.md @@ -0,0 +1,56 @@ +# Task B4 Report: conpty Runtime Adapter + +## Status: DONE + +## Files Created + +All under `backend/internal/adapters/runtime/conpty/`: + +- `client.go` - loopback TCP client helpers (dialHost, clientSendMessage, clientGetOutput, clientIsAlive, clientKill) +- `spawn.go` - hostSpawner type definition +- `spawn_other.go` (`//go:build !windows`) - stub returning "conpty spawn: unsupported on this OS" +- `spawn_windows.go` (`//go:build windows`) - real detached-process spawner using `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` and `HideWindow: true` via `golang.org/x/sys/windows` +- `pidalive_unix.go` (`//go:build !windows`) - `pidAlive` (signal-0 probe) + `defaultOSProcessFinder` +- `pidalive_windows.go` (`//go:build windows`) - `pidAlive` (OpenProcess/SYNCHRONIZE probe) + `defaultOSProcessFinder` +- `runtime.go` - `Runtime` struct, `New`, `Create`, `Destroy`, `IsAlive`, `SendMessage`, `GetOutput`, `resolve`, and `var _ ports.Runtime = (*Runtime)(nil)` compile-time assertion +- `runtime_test.go` - full test suite (13 test functions) + +## Session-Resolution + Spawn-Seam Design + +**Spawn seam:** `Options.Spawner` is a `hostSpawner` func. Production code uses `defaultSpawnHost` (Windows-only real spawn). Tests inject a fake spawner that starts an in-process `Serve` + `fakePTY` on a real `127.0.0.1:0` listener and returns that addr plus a fake PID. This makes all adapter methods testable on Darwin without a real ConPTY. + +**Session resolution:** `Runtime.resolve(id)` checks the in-memory `map[string]*hostSession` first (populated by `Create`). On a cache miss it calls `ptyregistry.List()` and scans for a matching `SessionID`, re-populating the map if found. This gives daemon-restart recovery: a pty-host spawned before a daemon restart is still reachable as long as its registry entry survives and its PID is alive. + +**Registry storage:** The loopback addr (e.g. "127.0.0.1:54321") is stored in `Entry.PipePath` (reusing the existing field whose semantic is "how to reach the host"). The pty-host OS PID is stored in `Entry.PtyHostPID` as specified. + +## Registry-Recovery Path + +`Create` calls `ptyregistry.Register` with the loopback addr in `PipePath` and the pty-host PID in `PtyHostPID`. `Destroy` calls `ptyregistry.Unregister`. `resolve` calls `ptyregistry.List()` (which auto-prunes dead-PID entries) and returns a `hostSession` if found. The `TestResolveViaRegistry` test verifies this: a `Runtime` with an empty in-memory map resolves `IsAlive` and `SendMessage` via the registry entry alone. + +**PID gotcha for tests:** `ptyregistry.List()` prunes entries whose `PtyHostPID` is not alive. Tests that need registry entries to survive the prune use `livePID()` (= `os.Getpid()`, always alive). The `Destroy` test uses `deadPID()` (= 2147483647, MaxInt32, never a real process) so the force-kill step is a safe no-op that does not accidentally kill the test runner. + +## Verification Command Outputs + +``` +go build ./... SUCCESS +GOOS=windows go build ./... SUCCESS +GOOS=linux go build ./... SUCCESS +go test -race ./internal/adapters/runtime/conpty/... 48 tests PASS (2 packages) +go vet ./internal/adapters/runtime/conpty/... No issues +``` + +## Windows-Only Spawn File (spawn_windows.go) + +`spawn_windows.go` was not executed (Darwin machine). The file compiles under `GOOS=windows go build ./...`. Key implementation notes: + +- Uses `golang.org/x/sys/windows.SysProcAttr.CreationFlags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` and `HideWindow: true`. This mirrors `detached: true, windowsHide: true` from the TS spawn. +- Reads stdout with a `bufio.Scanner` looking for `READY: ` (the format printed by `RunHost` in `host_main.go`). +- On success, calls `stdout.Close()` and `cmd.Process.Release()` to detach the child. `cmd.Process.Release()` is a best-effort detach; the error is intentionally ignored. +- 10s timeout matches the TS source (`10_000` ms). +- The env merge (inherit parent + overlay caller-provided vars) mirrors `{ ...process.env, ...config.environment }` from the TS. + +Concern: `cmd.Process.Release()` on Windows after `cmd.Start()` does not prevent the child from being reaped if our process exits; it only releases the OS handle held by the Go runtime. The child's `DETACHED_PROCESS` flag is what truly detaches it from our console group. This is correct behavior. + +## Interface Compliance + +`var _ ports.Runtime = (*Runtime)(nil)` compiles, confirming `Create`, `Destroy`, and `IsAlive` are all implemented with the correct signatures. `SendMessage` and `GetOutput` are exported but not part of the `ports.Runtime` interface - they are called by the daemon's messenger and output layers directly. `Attach` is intentionally omitted (B5). diff --git a/backend/internal/adapters/runtime/conpty/client.go b/backend/internal/adapters/runtime/conpty/client.go new file mode 100644 index 00000000..0ce7f61c --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/client.go @@ -0,0 +1,179 @@ +// client.go - loopback TCP client helpers that mirror pty-client.ts. +// Each function dials the host addr fresh (short-lived connection) and +// returns without maintaining state. Cross-platform: uses only stdlib net. +package conpty + +import ( + "encoding/json" + "net" + "time" +) + +const ( + // ptyInputChunkRunes is the max runes per terminal-input frame. + // Mirrors PTY_INPUT_CHUNK_CHARS in pty-client.ts. + ptyInputChunkRunes = 512 + // ptyInputChunkDelay is the inter-chunk delay. Mirrors PTY_INPUT_CHUNK_DELAY_MS. + ptyInputChunkDelay = 15 * time.Millisecond + // ptyInputEnterDelay is the pause before sending Enter. Mirrors PTY_INPUT_ENTER_DELAY_MS. + ptyInputEnterDelay = 300 * time.Millisecond + + dialTimeout = 3 * time.Second + getOutputTimeout = 3 * time.Second + isAliveTimeout = 2 * time.Second +) + +// dialHost opens a TCP connection to addr with a deadline. Callers close it. +func dialHost(addr string, timeout time.Duration) (net.Conn, error) { + return net.DialTimeout("tcp", addr, timeout) +} + +// clientSendMessage chunks message by 512 runes and sends each as a +// MsgTerminalInput frame with 15ms gaps, then pauses 300ms and sends "\r". +// Mirrors ptyHostSendMessage from pty-client.ts. +func clientSendMessage(addr, message string) error { + conn, err := dialHost(addr, dialTimeout) + if err != nil { + return err + } + defer conn.Close() + + runes := []rune(message) + for i := 0; i < len(runes); i += ptyInputChunkRunes { + end := i + ptyInputChunkRunes + if end > len(runes) { + end = len(runes) + } + chunk := string(runes[i:end]) + frame := EncodeMessage(MsgTerminalInput, []byte(chunk)) + if _, err := conn.Write(frame); err != nil { + return err + } + // Inter-chunk delay only between chunks, not after the last one. + if end < len(runes) { + time.Sleep(ptyInputChunkDelay) + } + } + + // Brief pause before Enter (matches TS: Enter sent as a separate frame). + time.Sleep(ptyInputEnterDelay) + frame := EncodeMessage(MsgTerminalInput, []byte("\r")) + _, err = conn.Write(frame) + return err +} + +// clientGetOutput sends MsgGetOutputReq and reads frames until MsgGetOutputRes. +// Returns "" on timeout or connection failure (no error), matching the TS. +// lines <= 0 is handled by the caller (runtime.go rejects it before calling). +func clientGetOutput(addr string, lines int) (string, error) { + conn, err := dialHost(addr, getOutputTimeout) + if err != nil { + return "", nil // ponytail: connect failure -> "" like the TS + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(getOutputTimeout)) + + req, _ := json.Marshal(GetOutputReq{Lines: lines}) + if _, err := conn.Write(EncodeMessage(MsgGetOutputReq, req)); err != nil { + return "", nil + } + + resultC := make(chan string, 1) + parser := NewMessageParser(func(msgType byte, payload []byte) { + if msgType == MsgGetOutputRes { + select { + case resultC <- string(payload): + default: + } + } + }) + + buf := make([]byte, 4096) + for { + n, err := conn.Read(buf) + if n > 0 { + parser.Feed(buf[:n]) + } + select { + case text := <-resultC: + return text, nil + default: + } + if err != nil { + break + } + } + // Drain the channel one last time after the read loop ends. + select { + case text := <-resultC: + return text, nil + default: + return "", nil // timeout or EOF before response + } +} + +// clientIsAlive probes the host with MsgStatusReq. Returns true if a valid +// MsgStatusRes with parseable JSON is received. Connect failure or timeout +// returns false. Mirrors ptyHostIsAlive from pty-client.ts: host reachable +// == alive, regardless of the inner agent's alive field. +func clientIsAlive(addr string) bool { + conn, err := dialHost(addr, isAliveTimeout) + if err != nil { + return false + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(isAliveTimeout)) + + if _, err := conn.Write(EncodeMessage(MsgStatusReq, nil)); err != nil { + return false + } + + aliveC := make(chan bool, 1) + parser := NewMessageParser(func(msgType byte, payload []byte) { + if msgType == MsgStatusRes { + var sp StatusPayload + alive := json.Unmarshal(payload, &sp) == nil + select { + case aliveC <- alive: + default: + } + } + }) + + buf := make([]byte, 4096) + for { + n, err := conn.Read(buf) + if n > 0 { + parser.Feed(buf[:n]) + } + select { + case result := <-aliveC: + return result + default: + } + if err != nil { + break + } + } + select { + case result := <-aliveC: + return result + default: + return false + } +} + +// clientKill sends MsgKillReq best-effort. Connect failure is a no-op +// (host already dead). Mirrors ptyHostKill from pty-client.ts. +func clientKill(addr string) error { + conn, err := dialHost(addr, isAliveTimeout) + if err != nil { + return nil // already dead + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(isAliveTimeout)) + _, _ = conn.Write(EncodeMessage(MsgKillReq, nil)) + return nil +} diff --git a/backend/internal/adapters/runtime/conpty/pidalive_unix.go b/backend/internal/adapters/runtime/conpty/pidalive_unix.go new file mode 100644 index 00000000..52f463ea --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/pidalive_unix.go @@ -0,0 +1,26 @@ +//go:build !windows + +package conpty + +import ( + "errors" + "os" + "syscall" +) + +// pidAlive probes PID liveness via signal 0. nil and EPERM both mean alive +// (process exists but may not be signallable). ESRCH means dead. +// Mirrors ptyregistry.defaultPidAlive (same signal-0 pattern). +func pidAlive(pid int) bool { + err := syscall.Kill(pid, 0) + if err == nil { + return true + } + return errors.Is(err, syscall.EPERM) +} + +// defaultOSProcessFinder wraps os.FindProcess for Unix (always succeeds on +// Unix; the returned handle is valid for Kill). +func defaultOSProcessFinder(pid int) (processKiller, error) { + return os.FindProcess(pid) +} diff --git a/backend/internal/adapters/runtime/conpty/pidalive_windows.go b/backend/internal/adapters/runtime/conpty/pidalive_windows.go new file mode 100644 index 00000000..a70a842a --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/pidalive_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package conpty + +import ( + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +// pidAlive probes PID liveness on Windows by opening the process handle with +// SYNCHRONIZE (minimal permission). Failure means the process is gone. +func pidAlive(pid int) bool { + h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) + if err != nil { + return false + } + _ = windows.CloseHandle(h) + return true +} + +// defaultOSProcessFinder wraps os.FindProcess for Windows. +func defaultOSProcessFinder(pid int) (processKiller, error) { + p, err := os.FindProcess(pid) + if err != nil { + return nil, fmt.Errorf("os.FindProcess(%d): %w", pid, err) + } + return p, nil +} diff --git a/backend/internal/adapters/runtime/conpty/runtime.go b/backend/internal/adapters/runtime/conpty/runtime.go new file mode 100644 index 00000000..e8abeebf --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/runtime.go @@ -0,0 +1,224 @@ +// runtime.go - conpty Runtime adapter. Implements ports.Runtime (minus Attach, +// which comes in B5). Drives sessions via the B3 pty-host over loopback TCP, +// using the B1 protocol and the B2 registry for restart recovery. +package conpty + +import ( + "context" + "fmt" + "regexp" + "sync" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Ensure Runtime satisfies the port at compile time. Attach is added in B5. +var _ ports.Runtime = (*Runtime)(nil) + +// validSessionID matches agent-orchestrator's assertValidSessionId. +var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +// hostSession is the in-memory state for a live pty-host connection. +type hostSession struct { + addr string + pid int +} + +// Options configures the Runtime. All fields are optional; zero values use +// sensible defaults. The Spawner field is injectable for tests. +type Options struct { + // Spawner overrides the default OS-level process spawner. If nil, + // defaultSpawnHost is used (Windows-only; returns an error on other OSes). + Spawner hostSpawner +} + +// Runtime is the conpty runtime adapter. +type Runtime struct { + spawner hostSpawner + + mu sync.Mutex + sessions map[string]*hostSession // sessionID -> live session +} + +// New creates a Runtime with the given options. +func New(opts Options) *Runtime { + sp := opts.Spawner + if sp == nil { + sp = defaultSpawnHost + } + return &Runtime{ + spawner: sp, + sessions: make(map[string]*hostSession), + } +} + +// Create spawns a detached pty-host for the session, waits for READY, stores +// the addr+pid in-memory and in the B2 registry, and returns the handle. +// Returns an error if sessionID is invalid, already exists, or spawn fails. +func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { + id := string(cfg.SessionID) + if !validSessionID.MatchString(id) { + return ports.RuntimeHandle{}, fmt.Errorf("conpty: invalid session id %q: must match ^[a-zA-Z0-9_-]+$", id) + } + if cfg.WorkspacePath == "" { + return ports.RuntimeHandle{}, fmt.Errorf("conpty: workspace path required") + } + if len(cfg.Argv) == 0 { + return ports.RuntimeHandle{}, fmt.Errorf("conpty: argv required") + } + + r.mu.Lock() + if _, dup := r.sessions[id]; dup { + r.mu.Unlock() + return ports.RuntimeHandle{}, fmt.Errorf("conpty: session %q already exists; destroy before re-creating", id) + } + // Reserve the slot before the async spawn so a concurrent Create for the + // same id fails immediately (no gap between check and set). + r.sessions[id] = nil + r.mu.Unlock() + + addr, pid, err := r.spawner(ctx, id, cfg.WorkspacePath, cfg.Argv, cfg.Env) + if err != nil { + r.mu.Lock() + delete(r.sessions, id) + r.mu.Unlock() + return ports.RuntimeHandle{}, fmt.Errorf("conpty: spawn pty-host for %q: %w", id, err) + } + + sess := &hostSession{addr: addr, pid: pid} + + r.mu.Lock() + r.sessions[id] = sess + r.mu.Unlock() + + // Register in B2 registry for daemon-restart recovery (best-effort). + _ = ptyregistry.Register(ptyregistry.Entry{ + SessionID: id, + PtyHostPID: pid, + PipePath: addr, // ponytail: reuse PipePath field for loopback addr + RegisteredAt: time.Now().UTC().Format(time.RFC3339), + }) + + return ports.RuntimeHandle{ID: id}, nil +} + +// Destroy gracefully kills the pty-host, waits up to ~500ms for the pid to +// exit, then force-kills it. Removes the session from the map and the registry. +// Idempotent: unknown/already-gone session returns nil. +func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error { + sess := r.resolve(handle.ID) + if sess == nil { + return nil // unknown or already gone + } + + // Ask host to shut down gracefully (triggers shutdown() in Serve). + _ = clientKill(sess.addr) + + // Poll up to ~500ms (20 x 25ms) for the pty-host pid to exit. + // ponytail: signal-0 probe; upgrade to process-tree kill if orphan ConPTY + // helpers appear. + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if !pidAlive(sess.pid) { + break + } + time.Sleep(25 * time.Millisecond) + } + + // Best-effort force-kill (the host's graceful shutdown already disposed + // the ConPTY child; killing the host process is sufficient). + if p, err := findProcess(sess.pid); err == nil { + _ = p.Kill() + } + + r.mu.Lock() + delete(r.sessions, handle.ID) + r.mu.Unlock() + + _ = ptyregistry.Unregister(handle.ID) + return nil +} + +// IsAlive returns (true, nil) if the pty-host is reachable, (false, nil) if +// not or if the session is unknown. Never returns a non-nil error that a reaper +// would treat as a death conclusion: unreachable host == definitively dead. +func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) { + sess := r.resolve(handle.ID) + if sess == nil { + return false, nil // no in-memory entry, no registry entry -> definitively gone + } + return clientIsAlive(sess.addr), nil +} + +// SendMessage chunks message and writes it to the pty-host followed by Enter. +func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { + sess := r.resolve(handle.ID) + if sess == nil { + return fmt.Errorf("conpty: session %q not found", handle.ID) + } + return clientSendMessage(sess.addr, message) +} + +// GetOutput returns the last lines lines from the pty-host ring buffer. +func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { + if lines <= 0 { + return "", fmt.Errorf("conpty: lines must be > 0") + } + sess := r.resolve(handle.ID) + if sess == nil { + return "", fmt.Errorf("conpty: session %q not found", handle.ID) + } + return clientGetOutput(sess.addr, lines) +} + +// resolve looks up a session by id: first the in-memory map, then the B2 +// registry (for daemon-restart recovery). Returns nil if not found either way. +func (r *Runtime) resolve(id string) *hostSession { + r.mu.Lock() + sess := r.sessions[id] + r.mu.Unlock() + if sess != nil { + return sess + } + + // Registry fallback: scan for the entry by session id. + entries, err := ptyregistry.List() + if err != nil { + return nil + } + for _, e := range entries { + if e.SessionID == id { + // Re-populate the map so subsequent calls skip the file scan. + recovered := &hostSession{addr: e.PipePath, pid: e.PtyHostPID} + r.mu.Lock() + // Only store if another goroutine hasn't beaten us. + if r.sessions[id] == nil { + r.sessions[id] = recovered + } else { + recovered = r.sessions[id] + } + r.mu.Unlock() + return recovered + } + } + return nil +} + +// findProcess wraps os.FindProcess to make it swappable in tests. +// ponytail: direct call; no interface needed at this scale. +func findProcess(pid int) (processKiller, error) { + p, err := osProcessFinder(pid) + return p, err +} + +// processKiller is the subset of *os.Process used by Destroy. +type processKiller interface { + Kill() error +} + +// osProcessFinder is the production implementation; tests may replace it. +// The real defaultOSProcessFinder is in pidalive_unix.go / pidalive_windows.go +// (same files that provide pidAlive). +var osProcessFinder = defaultOSProcessFinder diff --git a/backend/internal/adapters/runtime/conpty/runtime_test.go b/backend/internal/adapters/runtime/conpty/runtime_test.go new file mode 100644 index 00000000..459a9f01 --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/runtime_test.go @@ -0,0 +1,612 @@ +package conpty + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "os" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// livePID returns a PID that is guaranteed to be alive (the current process). +// Using this as the fake pty-host PID means ptyregistry.List() will not prune +// the entry during tests. Do NOT use this for the Destroy test: Destroy calls +// Kill on the pid, so use deadPID() there instead. +func livePID() int { return os.Getpid() } + +// deadPID returns a PID that is guaranteed to be dead (no process). This is +// used in Destroy tests so the force-kill step is a safe no-op. +// ponytail: PID 2147483647 (MaxInt32) is never a real process; signal-0 returns ESRCH. +func deadPID() int { return 2147483647 } + +// --------------------------------------------------------------------------- +// Test harness: in-process pty-host backed by a fakePTY. +// --------------------------------------------------------------------------- + +// inProcHost starts a Serve engine with a fakePTY on a real 127.0.0.1:0 +// listener and returns a fake spawner that returns that addr and a fake pid. +// The caller must call cleanup() to shut down the host. +type inProcHost struct { + addr string + pid int + pty *fakePTY + ring *Ring + cancel context.CancelFunc + done chan error + ln net.Listener +} + +func startInProcHost(t *testing.T, sessionID string, fakePID int) *inProcHost { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + pty := newFakePTY(fakePID) + ring := NewRing() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- Serve(ctx, ServeConfig{ + SessionID: sessionID, + Listener: ln, + PTY: pty, + Ring: ring, + }) + }() + return &inProcHost{ + addr: ln.Addr().String(), + pid: fakePID, + pty: pty, + ring: ring, + cancel: cancel, + done: done, + ln: ln, + } +} + +func (h *inProcHost) cleanup(t *testing.T) { + t.Helper() + h.cancel() + select { + case <-h.done: + case <-time.After(2 * time.Second): + t.Log("warning: inProcHost did not stop within 2s") + } +} + +// fakeSpawnerFor returns a hostSpawner that starts an in-process host for a +// single session ID and records which sessions have been spawned. +// The returned map maps sessionID -> *inProcHost for test inspection. +func fakeSpawnerFor(t *testing.T, hosts map[string]*inProcHost, fakePID int) hostSpawner { + t.Helper() + return func(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (string, int, error) { + h := startInProcHost(t, sessionID, fakePID) + if hosts != nil { + hosts[sessionID] = h + } + return h.addr, h.pid, nil + } +} + +// --------------------------------------------------------------------------- +// Redirect ptyregistry to a temp HOME so tests don't pollute ~/.ao +// --------------------------------------------------------------------------- + +func isolateRegistry(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +// TestCreate_RegistersSession verifies Create returns {ID: sessionID}, writes +// to the in-memory map, and registers in the ptyregistry. +func TestCreate_RegistersSession(t *testing.T) { + isolateRegistry(t) + hosts := map[string]*inProcHost{} + rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) + + ctx := context.Background() + handle, err := rt.Create(ctx, ports.RuntimeConfig{ + SessionID: domain.SessionID("sess-abc"), + WorkspacePath: "/tmp/workspace", + Argv: []string{"claude-code"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if handle.ID != "sess-abc" { + t.Fatalf("handle.ID = %q, want %q", handle.ID, "sess-abc") + } + + // In-memory map must have the entry. + rt.mu.Lock() + sess := rt.sessions["sess-abc"] + rt.mu.Unlock() + if sess == nil { + t.Fatal("session not in in-memory map after Create") + } + + // Registry must have the entry. + entries, err := ptyregistry.List() + if err != nil { + t.Fatalf("List: %v", err) + } + var found bool + for _, e := range entries { + if e.SessionID == "sess-abc" { + found = true + } + } + if !found { + t.Fatal("session not in registry after Create") + } + + hosts["sess-abc"].cleanup(t) +} + +// TestCreate_DuplicateErrors verifies a second Create for the same session id fails. +func TestCreate_DuplicateErrors(t *testing.T) { + isolateRegistry(t) + hosts := map[string]*inProcHost{} + rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) + ctx := context.Background() + + if _, err := rt.Create(ctx, ports.RuntimeConfig{ + SessionID: "sess-dup", + WorkspacePath: "/tmp/w", + Argv: []string{"sh"}, + }); err != nil { + t.Fatalf("first Create: %v", err) + } + + _, err := rt.Create(ctx, ports.RuntimeConfig{ + SessionID: "sess-dup", + WorkspacePath: "/tmp/w", + Argv: []string{"sh"}, + }) + if err == nil { + t.Fatal("expected error on duplicate Create, got nil") + } + if !strings.Contains(err.Error(), "already exists") { + t.Fatalf("error %q should contain 'already exists'", err.Error()) + } + + hosts["sess-dup"].cleanup(t) +} + +// TestCreate_InvalidIDErrors verifies Create rejects invalid session ids. +func TestCreate_InvalidIDErrors(t *testing.T) { + isolateRegistry(t) + rt := New(Options{Spawner: fakeSpawnerFor(t, nil, livePID())}) + ctx := context.Background() + + for _, bad := range []string{"", "has space", "has/slash", "has.dot"} { + _, err := rt.Create(ctx, ports.RuntimeConfig{ + SessionID: domain.SessionID(bad), + WorkspacePath: "/tmp/w", + Argv: []string{"sh"}, + }) + if err == nil { + t.Fatalf("Create(%q): expected error for invalid id, got nil", bad) + } + } +} + +// TestSendMessage_DeliversChunkedTextAndEnter verifies clientSendMessage sends +// the text + "\r" to the fakePTY input. +func TestSendMessage_DeliversChunkedTextAndEnter(t *testing.T) { + isolateRegistry(t) + hosts := map[string]*inProcHost{} + rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) + ctx := context.Background() + + handle, err := rt.Create(ctx, ports.RuntimeConfig{ + SessionID: "sess-sm", + WorkspacePath: "/tmp/w", + Argv: []string{"sh"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + h := hosts["sess-sm"] + defer h.cleanup(t) + + msg := "hello world" + // Collect PTY input in background. + inputC := make(chan []byte, 4) + go func() { + buf := make([]byte, 1024) + for { + n, err := h.pty.inR.Read(buf) + if n > 0 { + cp := make([]byte, n) + copy(cp, buf[:n]) + inputC <- cp + } + if err != nil { + return + } + } + }() + + if err := rt.SendMessage(ctx, handle, msg); err != nil { + t.Fatalf("SendMessage: %v", err) + } + + // Collect all received bytes within 2s. + var received []byte + deadline := time.After(2 * time.Second) + // Expect at least msg + "\r". + for !bytes.Contains(received, []byte("\r")) { + select { + case chunk := <-inputC: + received = append(received, chunk...) + case <-deadline: + t.Fatalf("timeout waiting for PTY input; got %q so far", received) + } + } + + if !bytes.HasPrefix(received, []byte(msg)) { + t.Fatalf("PTY input = %q, want prefix %q then \\r", received, msg) + } + if !bytes.Contains(received, []byte("\r")) { + t.Fatalf("PTY input = %q, missing trailing \\r", received) + } +} + +// TestSendMessage_LargeMessageChunked verifies a message > 512 runes is +// delivered correctly (host receives full text + "\r"). +func TestSendMessage_LargeMessageChunked(t *testing.T) { + isolateRegistry(t) + hosts := map[string]*inProcHost{} + rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) + ctx := context.Background() + + handle, _ := rt.Create(ctx, ports.RuntimeConfig{ + SessionID: "sess-lg", + WorkspacePath: "/tmp/w", + Argv: []string{"sh"}, + }) + h := hosts["sess-lg"] + defer h.cleanup(t) + + // Build a message longer than 512 runes (use multi-byte runes to test + // rune-boundary splitting). + var sb strings.Builder + for i := 0; i < 600; i++ { + sb.WriteRune('A' + rune(i%26)) + } + msg := sb.String() + + inputDone := make(chan []byte, 1) + go func() { + // Read until we see "\r". + var acc []byte + buf := make([]byte, 4096) + for { + n, err := h.pty.inR.Read(buf) + if n > 0 { + acc = append(acc, buf[:n]...) + } + if bytes.Contains(acc, []byte("\r")) { + inputDone <- acc + return + } + if err != nil { + inputDone <- acc + return + } + } + }() + + if err := rt.SendMessage(ctx, handle, msg); err != nil { + t.Fatalf("SendMessage: %v", err) + } + + select { + case got := <-inputDone: + // Strip trailing \r for comparison. + trimmed := strings.TrimSuffix(string(got), "\r") + if trimmed != msg { + t.Fatalf("PTY received %d chars, want %d\ngot: %q\nwant: %q", len(trimmed), len(msg), trimmed[:min(50, len(trimmed))], msg[:min(50, len(msg))]) + } + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for large message delivery") + } +} + +// TestGetOutput_ReturnsRingTail verifies GetOutput returns the ring's tail. +func TestGetOutput_ReturnsRingTail(t *testing.T) { + isolateRegistry(t) + hosts := map[string]*inProcHost{} + rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) + ctx := context.Background() + + handle, _ := rt.Create(ctx, ports.RuntimeConfig{ + SessionID: "sess-go", + WorkspacePath: "/tmp/w", + Argv: []string{"sh"}, + }) + h := hosts["sess-go"] + defer h.cleanup(t) + + // Seed the ring. + h.ring.Append([]byte("line1\nline2\nline3\n")) + + text, err := rt.GetOutput(ctx, handle, 2) + if err != nil { + t.Fatalf("GetOutput: %v", err) + } + want := h.ring.Tail(2) + if text != want { + t.Fatalf("GetOutput = %q, want %q", text, want) + } +} + +// TestIsAlive_TrueWhileServing_FalseAfterClose verifies IsAlive returns true +// while the host listens and false after its listener is closed. +func TestIsAlive_TrueWhileServing_FalseAfterClose(t *testing.T) { + isolateRegistry(t) + hosts := map[string]*inProcHost{} + rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) + ctx := context.Background() + + handle, _ := rt.Create(ctx, ports.RuntimeConfig{ + SessionID: "sess-ia", + WorkspacePath: "/tmp/w", + Argv: []string{"sh"}, + }) + h := hosts["sess-ia"] + + alive, err := rt.IsAlive(ctx, handle) + if err != nil { + t.Fatalf("IsAlive: %v", err) + } + if !alive { + t.Fatal("expected IsAlive=true while serving") + } + + // Shut down the host. + h.cancel() + <-h.done + + // Give the listener a moment to close. + time.Sleep(100 * time.Millisecond) + + alive2, err2 := rt.IsAlive(ctx, handle) + if err2 != nil { + t.Fatalf("IsAlive after close: %v", err2) + } + if alive2 { + t.Fatal("expected IsAlive=false after host closed") + } +} + +// TestIsAlive_FalseForUnknownSession verifies IsAlive returns (false, nil) for +// a session not in the map or registry. +func TestIsAlive_FalseForUnknownSession(t *testing.T) { + isolateRegistry(t) + rt := New(Options{Spawner: fakeSpawnerFor(t, nil, livePID())}) + ctx := context.Background() + + alive, err := rt.IsAlive(ctx, ports.RuntimeHandle{ID: "ghost-session"}) + if err != nil { + t.Fatalf("IsAlive: unexpected error: %v", err) + } + if alive { + t.Fatal("expected IsAlive=false for unknown session") + } +} + +// TestDestroy_KillsHostAndCleansUp verifies Destroy triggers clientKill, +// removes the map + registry entry, and is idempotent on second call. +// Uses deadPID() so the force-kill step is a safe no-op (the fake pty-host +// has no real OS process; clientKill already shut it down via the loopback). +func TestDestroy_KillsHostAndCleansUp(t *testing.T) { + isolateRegistry(t) + hosts := map[string]*inProcHost{} + rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, deadPID())}) + ctx := context.Background() + + handle, err := rt.Create(ctx, ports.RuntimeConfig{ + SessionID: "sess-destroy", + WorkspacePath: "/tmp/w", + Argv: []string{"sh"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + h := hosts["sess-destroy"] + + // Destroy should succeed. + if err := rt.Destroy(ctx, handle); err != nil { + t.Fatalf("Destroy: %v", err) + } + + // Wait for Serve to stop (clientKill triggers shutdown). + select { + case <-h.done: + case <-time.After(3 * time.Second): + t.Fatal("host did not stop after Destroy") + } + + // fakePTY.Close must have been called. + h.pty.closeMu.Lock() + closed := h.pty.closed + h.pty.closeMu.Unlock() + if !closed { + t.Fatal("expected fakePTY.Close() after Destroy") + } + + // Map entry must be gone. + rt.mu.Lock() + _, exists := rt.sessions["sess-destroy"] + rt.mu.Unlock() + if exists { + t.Fatal("expected map entry removed after Destroy") + } + + // Registry entry must be gone. + entries, _ := ptyregistry.List() + for _, e := range entries { + if e.SessionID == "sess-destroy" { + t.Fatal("expected registry entry removed after Destroy") + } + } + + // Second Destroy must be idempotent (returns nil). + if err := rt.Destroy(ctx, handle); err != nil { + t.Fatalf("second Destroy: expected nil, got %v", err) + } +} + +// TestResolveViaRegistry verifies that with an empty in-memory map but a +// registry entry pointing at a live in-process host, IsAlive and SendMessage +// still work (simulates a daemon restart). +func TestResolveViaRegistry(t *testing.T) { + isolateRegistry(t) + + // Start a host directly (not through Create) to simulate a pre-existing + // pty-host from a previous daemon run. Use the current process PID so + // ptyregistry.List() does not prune the entry as dead. + h := startInProcHost(t, "sess-reg", livePID()) + defer h.cleanup(t) + + // Manually register the host in the registry. + err := ptyregistry.Register(ptyregistry.Entry{ + SessionID: "sess-reg", + PtyHostPID: h.pid, + PipePath: h.addr, // addr stored in PipePath field + RegisteredAt: fmt.Sprintf("%d", time.Now().Unix()), + }) + if err != nil { + t.Fatalf("Register: %v", err) + } + + // Create a Runtime with an empty in-memory map (simulates daemon restart). + rt := New(Options{Spawner: fakeSpawnerFor(t, nil, livePID())}) + ctx := context.Background() + + // IsAlive must work via registry resolution. + alive, err := rt.IsAlive(ctx, ports.RuntimeHandle{ID: "sess-reg"}) + if err != nil { + t.Fatalf("IsAlive via registry: %v", err) + } + if !alive { + t.Fatal("expected IsAlive=true via registry resolution") + } + + // SendMessage must work via registry resolution. + inputC := make(chan []byte, 4) + go func() { + buf := make([]byte, 512) + for { + n, err := h.pty.inR.Read(buf) + if n > 0 { + cp := make([]byte, n) + copy(cp, buf[:n]) + inputC <- cp + } + if err != nil { + return + } + } + }() + + if err := rt.SendMessage(ctx, ports.RuntimeHandle{ID: "sess-reg"}, "ping"); err != nil { + t.Fatalf("SendMessage via registry: %v", err) + } + + // Collect PTY input. + var received []byte + deadline := time.After(3 * time.Second) + for !bytes.Contains(received, []byte("\r")) { + select { + case chunk := <-inputC: + received = append(received, chunk...) + case <-deadline: + t.Fatalf("timeout waiting for PTY input via registry; got %q", received) + } + } + if !bytes.Contains(received, []byte("ping")) { + t.Fatalf("PTY did not receive 'ping'; got %q", received) + } +} + +// --------------------------------------------------------------------------- +// Unit tests for client helpers (dial a fresh in-proc host directly). +// --------------------------------------------------------------------------- + +// TestClientGetOutput_TimesOutReturnsEmpty verifies clientGetOutput returns "" +// (no error) if no response arrives within the timeout. We test the happy path +// instead (timeout path would require a non-responding server). +func TestClientGetOutput_HappyPath(t *testing.T) { + f := startServe(t, 3001) + defer f.cancel() + + f.ring.Append([]byte("alpha\nbeta\ngamma\n")) + + text, err := clientGetOutput(f.addr, 2) + if err != nil { + t.Fatalf("clientGetOutput: %v", err) + } + want := f.ring.Tail(2) + if text != want { + t.Fatalf("clientGetOutput = %q, want %q", text, want) + } +} + +// TestClientIsAlive_TrueAndFalse verifies clientIsAlive returns true for a +// live host and false for an unreachable address. +func TestClientIsAlive_TrueAndFalse(t *testing.T) { + f := startServe(t, 3002) + defer f.cancel() + + if !clientIsAlive(f.addr) { + t.Fatal("clientIsAlive = false for live host, want true") + } + + f.cancel() + // Wait for listener to close. + select { + case <-f.done: + case <-time.After(2 * time.Second): + } + time.Sleep(50 * time.Millisecond) + + if clientIsAlive(f.addr) { + t.Fatal("clientIsAlive = true after host closed, want false") + } +} + +// TestClientKill_Idempotent verifies clientKill on a dead address returns nil. +func TestClientKill_Idempotent(t *testing.T) { + if err := clientKill("127.0.0.1:1"); err != nil { + t.Fatalf("clientKill on unreachable addr: %v", err) + } +} + +// helper for TestSendMessage_LargeMessageChunked +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// Ensure the packages compile (import check). +var _ = io.Discard diff --git a/backend/internal/adapters/runtime/conpty/spawn.go b/backend/internal/adapters/runtime/conpty/spawn.go new file mode 100644 index 00000000..a32a996f --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/spawn.go @@ -0,0 +1,11 @@ +// spawn.go - injectable hostSpawner seam. The real detached-process spawn is +// Windows-only (spawn_windows.go). This file defines the type and the +// defaultSpawnHost variable; the non-windows stub is in spawn_other.go. +package conpty + +import "context" + +// hostSpawner starts a detached pty-host for the session and returns its +// loopback address ("127.0.0.1:PORT") and OS pid once it prints READY. +// Injectable for tests: replace this field on Options before calling New. +type hostSpawner func(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (addr string, pid int, err error) diff --git a/backend/internal/adapters/runtime/conpty/spawn_other.go b/backend/internal/adapters/runtime/conpty/spawn_other.go new file mode 100644 index 00000000..342836aa --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/spawn_other.go @@ -0,0 +1,16 @@ +//go:build !windows + +// spawn_other.go - stub for non-Windows platforms. The real detached-process +// spawn lives in spawn_windows.go and uses Windows process-creation flags. +package conpty + +import ( + "context" + "errors" +) + +// defaultSpawnHost is a stub on non-Windows platforms. Tests inject their own +// spawner; this only needs to keep the package buildable on Darwin/Linux. +func defaultSpawnHost(_ context.Context, _, _ string, _ []string, _ map[string]string) (string, int, error) { + return "", 0, errors.New("conpty spawn: unsupported on this OS") +} diff --git a/backend/internal/adapters/runtime/conpty/spawn_windows.go b/backend/internal/adapters/runtime/conpty/spawn_windows.go new file mode 100644 index 00000000..e8de3d7e --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/spawn_windows.go @@ -0,0 +1,121 @@ +//go:build windows + +// spawn_windows.go - real detached pty-host spawner for Windows using +// CREATE_NEW_PROCESS_GROUP + DETACHED_PROCESS so the host survives daemon exit. +package conpty + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + + "golang.org/x/sys/windows" +) + +// readyRE matches the "READY: " line printed by RunHost. +var readyRE = regexp.MustCompile(`READY:(\d+) (\d+)`) + +const spawnReadyTimeout = 10 * time.Second + +// defaultSpawnHost resolves the current executable, builds the pty-host argv, +// and spawns it detached on Windows. It reads stdout for "READY: " +// with a 10s timeout, then unrefs (detaches) the child. Returns the loopback +// address and the pty-host OS PID. +func defaultSpawnHost(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (string, int, error) { + exe, err := os.Executable() + if err != nil { + return "", 0, fmt.Errorf("conpty spawn: resolve executable: %w", err) + } + + // Build: pty-host + args := append([]string{"pty-host", sessionID, cwd}, argv...) + + // Merge env: inherit parent, then overlay caller-provided vars. + merged := os.Environ() + for k, v := range env { + merged = append(merged, k+"="+v) + } + + cmd := exec.CommandContext(ctx, exe, args...) + cmd.Dir = cwd + cmd.Env = merged + + // Windows process-creation flags: detached + hidden console. + // ponytail: DETACHED_PROCESS puts the child in its own console; without it + // the child is killed when the parent's console closes. CREATE_NEW_PROCESS_GROUP + // insulates it from Ctrl+C sent to the parent. windowsHide suppresses the flash. + cmd.SysProcAttr = &windows.SysProcAttr{ + CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP, + HideWindow: true, + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", 0, fmt.Errorf("conpty spawn: stdout pipe: %w", err) + } + // Stderr is discarded; pty-host writes diagnostics there but we don't need them. + cmd.Stderr = io.Discard + + if err := cmd.Start(); err != nil { + return "", 0, fmt.Errorf("conpty spawn: start: %w", err) + } + + // Read READY line with a timeout. + readyC := make(chan struct { + addr string + pid int + err error + }, 1) + + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + m := readyRE.FindStringSubmatch(line) + if m != nil { + pid, _ := strconv.Atoi(m[1]) + port, _ := strconv.Atoi(m[2]) + readyC <- struct { + addr string + pid int + err error + }{"127.0.0.1:" + strconv.Itoa(port), pid, nil} + return + } + } + readyC <- struct { + addr string + pid int + err error + }{"", 0, fmt.Errorf("conpty spawn: pty-host exited without printing READY")} + }() + + timer := time.NewTimer(spawnReadyTimeout) + defer timer.Stop() + + select { + case r := <-readyC: + if r.err != nil { + _ = cmd.Process.Kill() + return "", 0, r.err + } + // Unref: detach stdout so the child is not blocked, then release reference + // so our process can exit while the child keeps running. + stdout.Close() + cmd.Process.Release() // nolint: errcheck - best-effort detach + return r.addr, cmd.Process.Pid, nil + case <-timer.C: + _ = cmd.Process.Kill() + return "", 0, fmt.Errorf("conpty spawn: pty-host startup timeout (%s)", spawnReadyTimeout) + case <-ctx.Done(): + _ = cmd.Process.Kill() + return "", 0, ctx.Err() + } +} From 0bc26e50e0b8bb48e72b67abd335bd62e1215f38 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 24 Jun 2026 00:05:37 +0530 Subject: [PATCH 14/60] fix(conpty): split IsAlive dead-vs-transient for reaper safety clientIsAlive collapsed every probe failure (dial timeout, read-deadline expiry, write error, connection-refused) to false, which the reaper turns into ProbeDead and the LCM can promote to a permanent reap. A single transient 2s loopback timeout would spuriously kill a live idle session. Now clientIsAlive returns (alive bool, transientErr error): a refused dial is definitively gone (false, nil); a timeout or any connected-then-failed I/O error is transient (false, err) so the reaper records ProbeFailed and retries. Wire IsAlive to propagate it. Add regression test covering both the refused-is-gone and timeout-is-transient paths. Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/task-b4-report.md | 47 ++++++++++++ .../adapters/runtime/conpty/client.go | 72 +++++++++++++++--- .../adapters/runtime/conpty/runtime.go | 18 ++++- .../adapters/runtime/conpty/runtime_test.go | 76 +++++++++++++++++-- 4 files changed, 191 insertions(+), 22 deletions(-) diff --git a/.superpowers/sdd/task-b4-report.md b/.superpowers/sdd/task-b4-report.md index 2b3d0029..d9442695 100644 --- a/.superpowers/sdd/task-b4-report.md +++ b/.superpowers/sdd/task-b4-report.md @@ -54,3 +54,50 @@ Concern: `cmd.Process.Release()` on Windows after `cmd.Start()` does not prevent ## Interface Compliance `var _ ports.Runtime = (*Runtime)(nil)` compiles, confirming `Create`, `Destroy`, and `IsAlive` are all implemented with the correct signatures. `SendMessage` and `GetOutput` are exported but not part of the `ports.Runtime` interface - they are called by the daemon's messenger and output layers directly. `Attach` is intentionally omitted (B5). + +--- + +## Review fix: IsAlive reaper-safety (dead-vs-transient split) + +### The bug +`clientIsAlive` returned a bare `bool`, collapsing dial timeout, read-deadline +expiry, write error, and connection-refused all to `false`. The reaper turns +`(false, nil)` into `ProbeDead`, which the LCM can promote to a permanent +`IsTerminated` reap when the agent has also been quiet >60s. A single transient +2s loopback timeout on a normal idle session would then spuriously kill a live +session. tmux/zellij avoid this by returning a non-nil error for transient +failures (recorded as `ProbeFailed`, ignored and retried). + +### Fix (files touched, all under conpty/) +- `client.go`: `clientIsAlive` now returns `(alive bool, transientErr error)`: + - valid `MSG_STATUS_RES` -> `(true, nil)`. + - dial refused (nothing listening) -> `(false, nil)` definitively gone. + - dial timeout / read-deadline expiry / write error / mid-read EOF / no + STATUS_RES before conn end -> `(false, err)` transient. + - Added `isTimeout` (net.Error.Timeout()) and `isConnRefused` + (errors.Is ECONNREFUSED, plus WSAECONNREFUSED 10061 for older Windows). + "When unsure, prefer transient": any non-timeout, non-refused dial error + returns the error. + - `clientGetOutput` "return empty on failure" behavior left unchanged. +- `runtime.go`: `IsAlive` now propagates `clientIsAlive`'s `(bool, error)` + directly; the unknown-session path still returns `(false, nil)`. +- `runtime_test.go`: + - Updated `TestClientIsAlive_TrueAndFalse` for the new 2-value signature + (refused -> (false, nil)). + - Added regression `TestIsAlive_RefusedIsGone_TimeoutIsTransient`: + (a) resolved-but-refused host -> `(false, nil)`; + (b) resolved host behind a listener that Accepts but never replies, so the + short `isAliveTimeout` read deadline fires -> `(false, non-nil err)`. + +### Command outputs (from backend/) +- `go build ./...` : Success +- `GOOS=windows go build ./...`: Success +- `GOOS=linux go build ./...` : Success +- `go test -race ./internal/adapters/runtime/conpty/...` : 49 passed in 2 packages +- `go vet ./internal/adapters/runtime/conpty/...` : No issues found +- New test verbose: TestIsAlive_RefusedIsGone_TimeoutIsTransient PASS (2.00s) + +### READY-format confirmation (host_main.go, not modified) +`host_main.go:54` prints `fmt.Fprintf(stdout, "READY:%d %d\n", pty.PID(), port)` +i.e. `READY: ` (pid THEN port), which matches spawn_windows.go's +`READY:(\d+) (\d+)` regex. No action needed (and out of scope for this task). diff --git a/backend/internal/adapters/runtime/conpty/client.go b/backend/internal/adapters/runtime/conpty/client.go index 0ce7f61c..0c882edd 100644 --- a/backend/internal/adapters/runtime/conpty/client.go +++ b/backend/internal/adapters/runtime/conpty/client.go @@ -5,7 +5,9 @@ package conpty import ( "encoding/json" + "errors" "net" + "syscall" "time" ) @@ -113,36 +115,57 @@ func clientGetOutput(addr string, lines int) (string, error) { } } -// clientIsAlive probes the host with MsgStatusReq. Returns true if a valid -// MsgStatusRes with parseable JSON is received. Connect failure or timeout -// returns false. Mirrors ptyHostIsAlive from pty-client.ts: host reachable -// == alive, regardless of the inner agent's alive field. -func clientIsAlive(addr string) bool { +// clientIsAlive probes the host with MsgStatusReq and distinguishes three +// outcomes for the reaper (see IsAlive in runtime.go): +// +// - alive==true, transientErr==nil: a valid MsgStatusRes was received. +// - alive==false, transientErr==nil: the host is DEFINITIVELY gone (the dial +// was refused: nothing is listening on the loopback addr). +// - alive==false, transientErr!=nil: a TRANSIENT probe failure (network +// timeout, or any connected-then-failed I/O error). The reaper records this +// as ProbeFailed and retries instead of reaping a possibly-live session. +// +// When unsure, we prefer transient (return the error) rather than reporting +// death. Mirrors ptyHostIsAlive from pty-client.ts on the alive path: host +// reachable == alive, regardless of the inner agent's alive field. +func clientIsAlive(addr string) (alive bool, transientErr error) { conn, err := dialHost(addr, isAliveTimeout) if err != nil { - return false + // A dial timeout is transient (the loopback hiccupped). A refused + // connection means nothing is listening -> definitively gone. Any + // other dial failure is treated as transient ("when unsure, retry"). + if isTimeout(err) { + return false, err + } + if isConnRefused(err) { + return false, nil + } + return false, err } defer conn.Close() _ = conn.SetDeadline(time.Now().Add(isAliveTimeout)) if _, err := conn.Write(EncodeMessage(MsgStatusReq, nil)); err != nil { - return false + // We connected, then the write failed: connected-then-failed I/O is + // transient (the host may still be up; the conn was disrupted). + return false, err } aliveC := make(chan bool, 1) parser := NewMessageParser(func(msgType byte, payload []byte) { if msgType == MsgStatusRes { var sp StatusPayload - alive := json.Unmarshal(payload, &sp) == nil + ok := json.Unmarshal(payload, &sp) == nil select { - case aliveC <- alive: + case aliveC <- ok: default: } } }) buf := make([]byte, 4096) + var lastErr error for { n, err := conn.Read(buf) if n > 0 { @@ -150,19 +173,44 @@ func clientIsAlive(addr string) bool { } select { case result := <-aliveC: - return result + return result, nil default: } if err != nil { + lastErr = err break } } select { case result := <-aliveC: - return result + return result, nil default: - return false + // Connected but never got a STATUS_RES: read timeout or mid-read EOF. + // This is a connected-then-failed I/O error -> transient. + if lastErr == nil { + lastErr = errors.New("conpty: no status response before connection ended") + } + return false, lastErr + } +} + +// isTimeout reports whether err is a network timeout (dial timeout or +// read-deadline expiry). Cross-platform via the net.Error interface. +func isTimeout(err error) bool { + var ne net.Error + return errors.As(err, &ne) && ne.Timeout() +} + +// isConnRefused reports whether err is a fast "connection refused" dial +// failure (nothing listening). errors.Is(ECONNREFUSED) covers Unix and modern +// Windows; the explicit WSAECONNREFUSED (10061) guards older Windows runtimes +// where the errno is not mapped to syscall.ECONNREFUSED. +func isConnRefused(err error) bool { + if errors.Is(err, syscall.ECONNREFUSED) { + return true } + const wsaeconnrefused = syscall.Errno(10061) + return errors.Is(err, wsaeconnrefused) } // clientKill sends MsgKillReq best-effort. Connect failure is a no-op diff --git a/backend/internal/adapters/runtime/conpty/runtime.go b/backend/internal/adapters/runtime/conpty/runtime.go index e8abeebf..818967bd 100644 --- a/backend/internal/adapters/runtime/conpty/runtime.go +++ b/backend/internal/adapters/runtime/conpty/runtime.go @@ -141,15 +141,25 @@ func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error return nil } -// IsAlive returns (true, nil) if the pty-host is reachable, (false, nil) if -// not or if the session is unknown. Never returns a non-nil error that a reaper -// would treat as a death conclusion: unreachable host == definitively dead. +// IsAlive distinguishes three outcomes so the reaper never spuriously reaps a +// live session on a transient probe failure: +// +// - (true, nil): the pty-host answered a status probe -> alive. +// - (false, nil): DEFINITIVELY gone. Either the session resolves to nothing +// (no in-memory entry and no registry entry), or the dial was refused +// (nothing listening on the loopback addr). +// - (false, err): a TRANSIENT probe failure (loopback timeout, connected- +// then-failed I/O). The reaper records ProbeFailed and retries rather than +// treating it as a death conclusion. +// +// tmux/zellij return a non-nil error for transient failures for the same +// reason; conpty matches that contract here. func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) { sess := r.resolve(handle.ID) if sess == nil { return false, nil // no in-memory entry, no registry entry -> definitively gone } - return clientIsAlive(sess.addr), nil + return clientIsAlive(sess.addr) } // SendMessage chunks message and writes it to the pty-host followed by Enter. diff --git a/backend/internal/adapters/runtime/conpty/runtime_test.go b/backend/internal/adapters/runtime/conpty/runtime_test.go index 459a9f01..9fc8bd96 100644 --- a/backend/internal/adapters/runtime/conpty/runtime_test.go +++ b/backend/internal/adapters/runtime/conpty/runtime_test.go @@ -570,14 +570,14 @@ func TestClientGetOutput_HappyPath(t *testing.T) { } } -// TestClientIsAlive_TrueAndFalse verifies clientIsAlive returns true for a -// live host and false for an unreachable address. +// TestClientIsAlive_TrueAndFalse verifies clientIsAlive returns (true, nil) for +// a live host and (false, nil) for a refused address (definitively gone). func TestClientIsAlive_TrueAndFalse(t *testing.T) { f := startServe(t, 3002) defer f.cancel() - if !clientIsAlive(f.addr) { - t.Fatal("clientIsAlive = false for live host, want true") + if alive, err := clientIsAlive(f.addr); err != nil || !alive { + t.Fatalf("clientIsAlive(live) = (%v, %v), want (true, nil)", alive, err) } f.cancel() @@ -588,8 +588,72 @@ func TestClientIsAlive_TrueAndFalse(t *testing.T) { } time.Sleep(50 * time.Millisecond) - if clientIsAlive(f.addr) { - t.Fatal("clientIsAlive = true after host closed, want false") + // After close the OS refuses the connection on the freed port -> gone. + if alive, err := clientIsAlive(f.addr); alive || err != nil { + t.Fatalf("clientIsAlive(closed) = (%v, %v), want (false, nil)", alive, err) + } +} + +// TestIsAlive_RefusedIsGone_TimeoutIsTransient is the reaper-safety regression +// test. It asserts the dead-vs-transient split that keeps a single transient +// loopback hiccup from spuriously reaping a live idle session: +// +// (a) a resolved-but-REFUSED host -> IsAlive == (false, nil) [ProbeDead] +// (b) a resolved host whose probe TIMES OUT -> (false, non-nil) [ProbeFailed] +func TestIsAlive_RefusedIsGone_TimeoutIsTransient(t *testing.T) { + isolateRegistry(t) + + // (a) Refused: bind+close a listener to obtain a port nothing listens on. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + refusedAddr := ln.Addr().String() + _ = ln.Close() + + rtRefused := New(Options{Spawner: fakeSpawnerFor(t, nil, livePID())}) + rtRefused.mu.Lock() + rtRefused.sessions["gone"] = &hostSession{addr: refusedAddr, pid: livePID()} + rtRefused.mu.Unlock() + + alive, err := rtRefused.IsAlive(context.Background(), ports.RuntimeHandle{ID: "gone"}) + if alive || err != nil { + t.Fatalf("IsAlive(refused) = (%v, %v), want (false, nil) definitively gone", alive, err) + } + + // (b) Transient timeout: a listener that Accepts but never replies. The + // short isAliveTimeout read deadline fires before any STATUS_RES arrives, + // which must surface as a non-nil (transient) error, not a death. + silent, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen silent: %v", err) + } + defer silent.Close() + go func() { + for { + c, err := silent.Accept() + if err != nil { + return + } + // Hold the connection open without ever sending a STATUS_RES. + go func(c net.Conn) { + time.Sleep(isAliveTimeout + time.Second) + _ = c.Close() + }(c) + } + }() + + rtSilent := New(Options{Spawner: fakeSpawnerFor(t, nil, livePID())}) + rtSilent.mu.Lock() + rtSilent.sessions["stuck"] = &hostSession{addr: silent.Addr().String(), pid: livePID()} + rtSilent.mu.Unlock() + + alive, err = rtSilent.IsAlive(context.Background(), ports.RuntimeHandle{ID: "stuck"}) + if alive { + t.Fatalf("IsAlive(silent) alive=true, want false") + } + if err == nil { + t.Fatal("IsAlive(silent) err=nil, want non-nil transient error so the reaper records ProbeFailed") } } From 8d757ed13cc9634c62d8b02d932ff07a94711770 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 24 Jun 2026 00:08:22 +0530 Subject: [PATCH 15/60] chore(sdd): B5 brief + ledger Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/progress.md | 2 +- .superpowers/sdd/task-b4-review-package.txt | 1806 +++++++++++++++++++ .superpowers/sdd/task-b5-brief.md | 159 ++ 3 files changed, 1966 insertions(+), 1 deletion(-) create mode 100644 .superpowers/sdd/task-b4-review-package.txt create mode 100644 .superpowers/sdd/task-b5-brief.md diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index cbfb8649..cbbb5f65 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -28,7 +28,7 @@ Tasks: - B1: conpty protocol codec + ring buffer (cross-platform, fully unit-tested) — COMPLETE (926ee31..d67941b, 16 tests, -race clean, review APPROVED) - B2: windows pty-host registry — COMPLETE (..6552e26, 10 tests -race clean, win+linux+darwin build) - B3: pty-host serve engine (loopback TCP, NOT named pipe; ConPTY behind interface seam) — COMPLETE (41ce417..6cd6e2a, 35 tests -race clean incl. ordering regression test, review APPROVED; loopback decision documented; Windows go-pty file compile-checked only) -- B4: conpty runtime adapter (Create/Destroy/IsAlive/SendMessage/GetOutput/Attach over loopback) — IN PROGRESS +- B4: conpty runtime adapter — COMPLETE (be06609..0bc26e5, 49 tests -race, IsAlive dead-vs-transient fixed, registry-recovery path; Windows spawn file compile-checked only) - B5: terminal Attacher/Stream interface change + tmux/zellij/conpty Attach impls — Darwin-testable — NOT STARTED - B6: select conpty on Windows + delete zellij + doctor/spawn + register pty-host subcommand — NOT STARTED diff --git a/.superpowers/sdd/task-b4-review-package.txt b/.superpowers/sdd/task-b4-review-package.txt new file mode 100644 index 00000000..a2e1bfcc --- /dev/null +++ b/.superpowers/sdd/task-b4-review-package.txt @@ -0,0 +1,1806 @@ +=== COMMITS === +4aac665 feat(conpty): add runtime adapter with loopback pty-client and sessio... + +=== STAT === +backend/internal/adapters/runtime/conpty/client.go | 179 ++++++ + .../adapters/runtime/conpty/pidalive_unix.go | 26 + + .../adapters/runtime/conpty/pidalive_windows.go | 30 + + .../internal/adapters/runtime/conpty/runtime.go | 224 ++++++++ + .../adapters/runtime/conpty/runtime_test.go | 612 +++++++++++++++++++++ + backend/internal/adapters/runtime/conpty/spawn.go | 11 + + .../adapters/runtime/conpty/spawn_other.go | 16 + + .../adapters/runtime/conpty/spawn_windows.go | 121 ++++ + 8 files changed, 1219 insertions(+) + +=== DIFF === +backend/internal/adapters/runtime/conpty/client.go | 179 ++++++ + .../adapters/runtime/conpty/pidalive_unix.go | 26 + + .../adapters/runtime/conpty/pidalive_windows.go | 30 + + .../internal/adapters/runtime/conpty/runtime.go | 224 ++++++++ + .../adapters/runtime/conpty/runtime_test.go | 612 +++++++++++++++++++++ + backend/internal/adapters/runtime/conpty/spawn.go | 11 + + .../adapters/runtime/conpty/spawn_other.go | 16 + + .../adapters/runtime/conpty/spawn_windows.go | 121 ++++ + 8 files changed, 1219 insertions(+) + +diff --git a/backend/internal/adapters/runtime/conpty/client.go b/backend/internal/adapters/runtime/conpty/client.go +new file mode 100644 +index 0000000..0ce7f61 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/client.go +@@ -0,0 +1,179 @@ ++// client.go - loopback TCP client helpers that mirror pty-client.ts. ++// Each function dials the host addr fresh (short-lived connection) and ++// returns without maintaining state. Cross-platform: uses only stdlib net. ++package conpty ++ ++import ( ++ "encoding/json" ++ "net" ++ "time" ++) ++ ++const ( ++ // ptyInputChunkRunes is the max runes per terminal-input frame. ++ // Mirrors PTY_INPUT_CHUNK_CHARS in pty-client.ts. ++ ptyInputChunkRunes = 512 ++ // ptyInputChunkDelay is the inter-chunk delay. Mirrors PTY_INPUT_CHUNK_DELAY_MS. ++ ptyInputChunkDelay = 15 * time.Millisecond ++ // ptyInputEnterDelay is the pause before sending Enter. Mirrors PTY_INPUT_ENTER_DELAY_MS. ++ ptyInputEnterDelay = 300 * time.Millisecond ++ ++ dialTimeout = 3 * time.Second ++ getOutputTimeout = 3 * time.Second ++ isAliveTimeout = 2 * time.Second ++) ++ ++// dialHost opens a TCP connection to addr with a deadline. Callers close it. ++func dialHost(addr string, timeout time.Duration) (net.Conn, error) { ++ return net.DialTimeout("tcp", addr, timeout) ++} ++ ++// clientSendMessage chunks message by 512 runes and sends each as a ++// MsgTerminalInput frame with 15ms gaps, then pauses 300ms and sends "\r". ++// Mirrors ptyHostSendMessage from pty-client.ts. ++func clientSendMessage(addr, message string) error { ++ conn, err := dialHost(addr, dialTimeout) ++ if err != nil { ++ return err ++ } ++ defer conn.Close() ++ ++ runes := []rune(message) ++ for i := 0; i < len(runes); i += ptyInputChunkRunes { ++ end := i + ptyInputChunkRunes ++ if end > len(runes) { ++ end = len(runes) ++ } ++ chunk := string(runes[i:end]) ++ frame := EncodeMessage(MsgTerminalInput, []byte(chunk)) ++ if _, err := conn.Write(frame); err != nil { ++ return err ++ } ++ // Inter-chunk delay only between chunks, not after the last one. ++ if end < len(runes) { ++ time.Sleep(ptyInputChunkDelay) ++ } ++ } ++ ++ // Brief pause before Enter (matches TS: Enter sent as a separate frame). ++ time.Sleep(ptyInputEnterDelay) ++ frame := EncodeMessage(MsgTerminalInput, []byte("\r")) ++ _, err = conn.Write(frame) ++ return err ++} ++ ++// clientGetOutput sends MsgGetOutputReq and reads frames until MsgGetOutputRes. ++// Returns "" on timeout or connection failure (no error), matching the TS. ++// lines <= 0 is handled by the caller (runtime.go rejects it before calling). ++func clientGetOutput(addr string, lines int) (string, error) { ++ conn, err := dialHost(addr, getOutputTimeout) ++ if err != nil { ++ return "", nil // ponytail: connect failure -> "" like the TS ++ } ++ defer conn.Close() ++ ++ _ = conn.SetDeadline(time.Now().Add(getOutputTimeout)) ++ ++ req, _ := json.Marshal(GetOutputReq{Lines: lines}) ++ if _, err := conn.Write(EncodeMessage(MsgGetOutputReq, req)); err != nil { ++ return "", nil ++ } ++ ++ resultC := make(chan string, 1) ++ parser := NewMessageParser(func(msgType byte, payload []byte) { ++ if msgType == MsgGetOutputRes { ++ select { ++ case resultC <- string(payload): ++ default: ++ } ++ } ++ }) ++ ++ buf := make([]byte, 4096) ++ for { ++ n, err := conn.Read(buf) ++ if n > 0 { ++ parser.Feed(buf[:n]) ++ } ++ select { ++ case text := <-resultC: ++ return text, nil ++ default: ++ } ++ if err != nil { ++ break ++ } ++ } ++ // Drain the channel one last time after the read loop ends. ++ select { ++ case text := <-resultC: ++ return text, nil ++ default: ++ return "", nil // timeout or EOF before response ++ } ++} ++ ++// clientIsAlive probes the host with MsgStatusReq. Returns true if a valid ++// MsgStatusRes with parseable JSON is received. Connect failure or timeout ++// returns false. Mirrors ptyHostIsAlive from pty-client.ts: host reachable ++// == alive, regardless of the inner agent's alive field. ++func clientIsAlive(addr string) bool { ++ conn, err := dialHost(addr, isAliveTimeout) ++ if err != nil { ++ return false ++ } ++ defer conn.Close() ++ ++ _ = conn.SetDeadline(time.Now().Add(isAliveTimeout)) ++ ++ if _, err := conn.Write(EncodeMessage(MsgStatusReq, nil)); err != nil { ++ return false ++ } ++ ++ aliveC := make(chan bool, 1) ++ parser := NewMessageParser(func(msgType byte, payload []byte) { ++ if msgType == MsgStatusRes { ++ var sp StatusPayload ++ alive := json.Unmarshal(payload, &sp) == nil ++ select { ++ case aliveC <- alive: ++ default: ++ } ++ } ++ }) ++ ++ buf := make([]byte, 4096) ++ for { ++ n, err := conn.Read(buf) ++ if n > 0 { ++ parser.Feed(buf[:n]) ++ } ++ select { ++ case result := <-aliveC: ++ return result ++ default: ++ } ++ if err != nil { ++ break ++ } ++ } ++ select { ++ case result := <-aliveC: ++ return result ++ default: ++ return false ++ } ++} ++ ++// clientKill sends MsgKillReq best-effort. Connect failure is a no-op ++// (host already dead). Mirrors ptyHostKill from pty-client.ts. ++func clientKill(addr string) error { ++ conn, err := dialHost(addr, isAliveTimeout) ++ if err != nil { ++ return nil // already dead ++ } ++ defer conn.Close() ++ _ = conn.SetDeadline(time.Now().Add(isAliveTimeout)) ++ _, _ = conn.Write(EncodeMessage(MsgKillReq, nil)) ++ return nil ++} +diff --git a/backend/internal/adapters/runtime/conpty/pidalive_unix.go b/backend/internal/adapters/runtime/conpty/pidalive_unix.go +new file mode 100644 +index 0000000..52f463e +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/pidalive_unix.go +@@ -0,0 +1,26 @@ ++//go:build !windows ++ ++package conpty ++ ++import ( ++ "errors" ++ "os" ++ "syscall" ++) ++ ++// pidAlive probes PID liveness via signal 0. nil and EPERM both mean alive ++// (process exists but may not be signallable). ESRCH means dead. ++// Mirrors ptyregistry.defaultPidAlive (same signal-0 pattern). ++func pidAlive(pid int) bool { ++ err := syscall.Kill(pid, 0) ++ if err == nil { ++ return true ++ } ++ return errors.Is(err, syscall.EPERM) ++} ++ ++// defaultOSProcessFinder wraps os.FindProcess for Unix (always succeeds on ++// Unix; the returned handle is valid for Kill). ++func defaultOSProcessFinder(pid int) (processKiller, error) { ++ return os.FindProcess(pid) ++} +diff --git a/backend/internal/adapters/runtime/conpty/pidalive_windows.go b/backend/internal/adapters/runtime/conpty/pidalive_windows.go +new file mode 100644 +index 0000000..a70a842 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/pidalive_windows.go +@@ -0,0 +1,30 @@ ++//go:build windows ++ ++package conpty ++ ++import ( ++ "fmt" ++ "os" ++ ++ "golang.org/x/sys/windows" ++) ++ ++// pidAlive probes PID liveness on Windows by opening the process handle with ++// SYNCHRONIZE (minimal permission). Failure means the process is gone. ++func pidAlive(pid int) bool { ++ h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) ++ if err != nil { ++ return false ++ } ++ _ = windows.CloseHandle(h) ++ return true ++} ++ ++// defaultOSProcessFinder wraps os.FindProcess for Windows. ++func defaultOSProcessFinder(pid int) (processKiller, error) { ++ p, err := os.FindProcess(pid) ++ if err != nil { ++ return nil, fmt.Errorf("os.FindProcess(%d): %w", pid, err) ++ } ++ return p, nil ++} +diff --git a/backend/internal/adapters/runtime/conpty/runtime.go b/backend/internal/adapters/runtime/conpty/runtime.go +new file mode 100644 +index 0000000..e8abeeb +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/runtime.go +@@ -0,0 +1,224 @@ ++// runtime.go - conpty Runtime adapter. Implements ports.Runtime (minus Attach, ++// which comes in B5). Drives sessions via the B3 pty-host over loopback TCP, ++// using the B1 protocol and the B2 registry for restart recovery. ++package conpty ++ ++import ( ++ "context" ++ "fmt" ++ "regexp" ++ "sync" ++ "time" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" ++) ++ ++// Ensure Runtime satisfies the port at compile time. Attach is added in B5. ++var _ ports.Runtime = (*Runtime)(nil) ++ ++// validSessionID matches agent-orchestrator's assertValidSessionId. ++var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) ++ ++// hostSession is the in-memory state for a live pty-host connection. ++type hostSession struct { ++ addr string ++ pid int ++} ++ ++// Options configures the Runtime. All fields are optional; zero values use ++// sensible defaults. The Spawner field is injectable for tests. ++type Options struct { ++ // Spawner overrides the default OS-level process spawner. If nil, ++ // defaultSpawnHost is used (Windows-only; returns an error on other OSes). ++ Spawner hostSpawner ++} ++ ++// Runtime is the conpty runtime adapter. ++type Runtime struct { ++ spawner hostSpawner ++ ++ mu sync.Mutex ++ sessions map[string]*hostSession // sessionID -> live session ++} ++ ++// New creates a Runtime with the given options. ++func New(opts Options) *Runtime { ++ sp := opts.Spawner ++ if sp == nil { ++ sp = defaultSpawnHost ++ } ++ return &Runtime{ ++ spawner: sp, ++ sessions: make(map[string]*hostSession), ++ } ++} ++ ++// Create spawns a detached pty-host for the session, waits for READY, stores ++// the addr+pid in-memory and in the B2 registry, and returns the handle. ++// Returns an error if sessionID is invalid, already exists, or spawn fails. ++func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { ++ id := string(cfg.SessionID) ++ if !validSessionID.MatchString(id) { ++ return ports.RuntimeHandle{}, fmt.Errorf("conpty: invalid session id %q: must match ^[a-zA-Z0-9_-]+$", id) ++ } ++ if cfg.WorkspacePath == "" { ++ return ports.RuntimeHandle{}, fmt.Errorf("conpty: workspace path required") ++ } ++ if len(cfg.Argv) == 0 { ++ return ports.RuntimeHandle{}, fmt.Errorf("conpty: argv required") ++ } ++ ++ r.mu.Lock() ++ if _, dup := r.sessions[id]; dup { ++ r.mu.Unlock() ++ return ports.RuntimeHandle{}, fmt.Errorf("conpty: session %q already exists; destroy before re-creating", id) ++ } ++ // Reserve the slot before the async spawn so a concurrent Create for the ++ // same id fails immediately (no gap between check and set). ++ r.sessions[id] = nil ++ r.mu.Unlock() ++ ++ addr, pid, err := r.spawner(ctx, id, cfg.WorkspacePath, cfg.Argv, cfg.Env) ++ if err != nil { ++ r.mu.Lock() ++ delete(r.sessions, id) ++ r.mu.Unlock() ++ return ports.RuntimeHandle{}, fmt.Errorf("conpty: spawn pty-host for %q: %w", id, err) ++ } ++ ++ sess := &hostSession{addr: addr, pid: pid} ++ ++ r.mu.Lock() ++ r.sessions[id] = sess ++ r.mu.Unlock() ++ ++ // Register in B2 registry for daemon-restart recovery (best-effort). ++ _ = ptyregistry.Register(ptyregistry.Entry{ ++ SessionID: id, ++ PtyHostPID: pid, ++ PipePath: addr, // ponytail: reuse PipePath field for loopback addr ++ RegisteredAt: time.Now().UTC().Format(time.RFC3339), ++ }) ++ ++ return ports.RuntimeHandle{ID: id}, nil ++} ++ ++// Destroy gracefully kills the pty-host, waits up to ~500ms for the pid to ++// exit, then force-kills it. Removes the session from the map and the registry. ++// Idempotent: unknown/already-gone session returns nil. ++func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error { ++ sess := r.resolve(handle.ID) ++ if sess == nil { ++ return nil // unknown or already gone ++ } ++ ++ // Ask host to shut down gracefully (triggers shutdown() in Serve). ++ _ = clientKill(sess.addr) ++ ++ // Poll up to ~500ms (20 x 25ms) for the pty-host pid to exit. ++ // ponytail: signal-0 probe; upgrade to process-tree kill if orphan ConPTY ++ // helpers appear. ++ deadline := time.Now().Add(500 * time.Millisecond) ++ for time.Now().Before(deadline) { ++ if !pidAlive(sess.pid) { ++ break ++ } ++ time.Sleep(25 * time.Millisecond) ++ } ++ ++ // Best-effort force-kill (the host's graceful shutdown already disposed ++ // the ConPTY child; killing the host process is sufficient). ++ if p, err := findProcess(sess.pid); err == nil { ++ _ = p.Kill() ++ } ++ ++ r.mu.Lock() ++ delete(r.sessions, handle.ID) ++ r.mu.Unlock() ++ ++ _ = ptyregistry.Unregister(handle.ID) ++ return nil ++} ++ ++// IsAlive returns (true, nil) if the pty-host is reachable, (false, nil) if ++// not or if the session is unknown. Never returns a non-nil error that a reaper ++// would treat as a death conclusion: unreachable host == definitively dead. ++func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) { ++ sess := r.resolve(handle.ID) ++ if sess == nil { ++ return false, nil // no in-memory entry, no registry entry -> definitively gone ++ } ++ return clientIsAlive(sess.addr), nil ++} ++ ++// SendMessage chunks message and writes it to the pty-host followed by Enter. ++func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { ++ sess := r.resolve(handle.ID) ++ if sess == nil { ++ return fmt.Errorf("conpty: session %q not found", handle.ID) ++ } ++ return clientSendMessage(sess.addr, message) ++} ++ ++// GetOutput returns the last lines lines from the pty-host ring buffer. ++func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { ++ if lines <= 0 { ++ return "", fmt.Errorf("conpty: lines must be > 0") ++ } ++ sess := r.resolve(handle.ID) ++ if sess == nil { ++ return "", fmt.Errorf("conpty: session %q not found", handle.ID) ++ } ++ return clientGetOutput(sess.addr, lines) ++} ++ ++// resolve looks up a session by id: first the in-memory map, then the B2 ++// registry (for daemon-restart recovery). Returns nil if not found either way. ++func (r *Runtime) resolve(id string) *hostSession { ++ r.mu.Lock() ++ sess := r.sessions[id] ++ r.mu.Unlock() ++ if sess != nil { ++ return sess ++ } ++ ++ // Registry fallback: scan for the entry by session id. ++ entries, err := ptyregistry.List() ++ if err != nil { ++ return nil ++ } ++ for _, e := range entries { ++ if e.SessionID == id { ++ // Re-populate the map so subsequent calls skip the file scan. ++ recovered := &hostSession{addr: e.PipePath, pid: e.PtyHostPID} ++ r.mu.Lock() ++ // Only store if another goroutine hasn't beaten us. ++ if r.sessions[id] == nil { ++ r.sessions[id] = recovered ++ } else { ++ recovered = r.sessions[id] ++ } ++ r.mu.Unlock() ++ return recovered ++ } ++ } ++ return nil ++} ++ ++// findProcess wraps os.FindProcess to make it swappable in tests. ++// ponytail: direct call; no interface needed at this scale. ++func findProcess(pid int) (processKiller, error) { ++ p, err := osProcessFinder(pid) ++ return p, err ++} ++ ++// processKiller is the subset of *os.Process used by Destroy. ++type processKiller interface { ++ Kill() error ++} ++ ++// osProcessFinder is the production implementation; tests may replace it. ++// The real defaultOSProcessFinder is in pidalive_unix.go / pidalive_windows.go ++// (same files that provide pidAlive). ++var osProcessFinder = defaultOSProcessFinder +diff --git a/backend/internal/adapters/runtime/conpty/runtime_test.go b/backend/internal/adapters/runtime/conpty/runtime_test.go +new file mode 100644 +index 0000000..459a9f0 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/runtime_test.go +@@ -0,0 +1,612 @@ ++package conpty ++ ++import ( ++ "bytes" ++ "context" ++ "fmt" ++ "io" ++ "net" ++ "os" ++ "strings" ++ "testing" ++ "time" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" ++ "github.com/aoagents/agent-orchestrator/backend/internal/domain" ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" ++) ++ ++// livePID returns a PID that is guaranteed to be alive (the current process). ++// Using this as the fake pty-host PID means ptyregistry.List() will not prune ++// the entry during tests. Do NOT use this for the Destroy test: Destroy calls ++// Kill on the pid, so use deadPID() there instead. ++func livePID() int { return os.Getpid() } ++ ++// deadPID returns a PID that is guaranteed to be dead (no process). This is ++// used in Destroy tests so the force-kill step is a safe no-op. ++// ponytail: PID 2147483647 (MaxInt32) is never a real process; signal-0 returns ESRCH. ++func deadPID() int { return 2147483647 } ++ ++// --------------------------------------------------------------------------- ++// Test harness: in-process pty-host backed by a fakePTY. ++// --------------------------------------------------------------------------- ++ ++// inProcHost starts a Serve engine with a fakePTY on a real 127.0.0.1:0 ++// listener and returns a fake spawner that returns that addr and a fake pid. ++// The caller must call cleanup() to shut down the host. ++type inProcHost struct { ++ addr string ++ pid int ++ pty *fakePTY ++ ring *Ring ++ cancel context.CancelFunc ++ done chan error ++ ln net.Listener ++} ++ ++func startInProcHost(t *testing.T, sessionID string, fakePID int) *inProcHost { ++ t.Helper() ++ ln, err := net.Listen("tcp", "127.0.0.1:0") ++ if err != nil { ++ t.Fatalf("listen: %v", err) ++ } ++ pty := newFakePTY(fakePID) ++ ring := NewRing() ++ ctx, cancel := context.WithCancel(context.Background()) ++ done := make(chan error, 1) ++ go func() { ++ done <- Serve(ctx, ServeConfig{ ++ SessionID: sessionID, ++ Listener: ln, ++ PTY: pty, ++ Ring: ring, ++ }) ++ }() ++ return &inProcHost{ ++ addr: ln.Addr().String(), ++ pid: fakePID, ++ pty: pty, ++ ring: ring, ++ cancel: cancel, ++ done: done, ++ ln: ln, ++ } ++} ++ ++func (h *inProcHost) cleanup(t *testing.T) { ++ t.Helper() ++ h.cancel() ++ select { ++ case <-h.done: ++ case <-time.After(2 * time.Second): ++ t.Log("warning: inProcHost did not stop within 2s") ++ } ++} ++ ++// fakeSpawnerFor returns a hostSpawner that starts an in-process host for a ++// single session ID and records which sessions have been spawned. ++// The returned map maps sessionID -> *inProcHost for test inspection. ++func fakeSpawnerFor(t *testing.T, hosts map[string]*inProcHost, fakePID int) hostSpawner { ++ t.Helper() ++ return func(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (string, int, error) { ++ h := startInProcHost(t, sessionID, fakePID) ++ if hosts != nil { ++ hosts[sessionID] = h ++ } ++ return h.addr, h.pid, nil ++ } ++} ++ ++// --------------------------------------------------------------------------- ++// Redirect ptyregistry to a temp HOME so tests don't pollute ~/.ao ++// --------------------------------------------------------------------------- ++ ++func isolateRegistry(t *testing.T) { ++ t.Helper() ++ dir := t.TempDir() ++ t.Setenv("HOME", dir) ++} ++ ++// --------------------------------------------------------------------------- ++// Tests ++// --------------------------------------------------------------------------- ++ ++// TestCreate_RegistersSession verifies Create returns {ID: sessionID}, writes ++// to the in-memory map, and registers in the ptyregistry. ++func TestCreate_RegistersSession(t *testing.T) { ++ isolateRegistry(t) ++ hosts := map[string]*inProcHost{} ++ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) ++ ++ ctx := context.Background() ++ handle, err := rt.Create(ctx, ports.RuntimeConfig{ ++ SessionID: domain.SessionID("sess-abc"), ++ WorkspacePath: "/tmp/workspace", ++ Argv: []string{"claude-code"}, ++ }) ++ if err != nil { ++ t.Fatalf("Create: %v", err) ++ } ++ ++ if handle.ID != "sess-abc" { ++ t.Fatalf("handle.ID = %q, want %q", handle.ID, "sess-abc") ++ } ++ ++ // In-memory map must have the entry. ++ rt.mu.Lock() ++ sess := rt.sessions["sess-abc"] ++ rt.mu.Unlock() ++ if sess == nil { ++ t.Fatal("session not in in-memory map after Create") ++ } ++ ++ // Registry must have the entry. ++ entries, err := ptyregistry.List() ++ if err != nil { ++ t.Fatalf("List: %v", err) ++ } ++ var found bool ++ for _, e := range entries { ++ if e.SessionID == "sess-abc" { ++ found = true ++ } ++ } ++ if !found { ++ t.Fatal("session not in registry after Create") ++ } ++ ++ hosts["sess-abc"].cleanup(t) ++} ++ ++// TestCreate_DuplicateErrors verifies a second Create for the same session id fails. ++func TestCreate_DuplicateErrors(t *testing.T) { ++ isolateRegistry(t) ++ hosts := map[string]*inProcHost{} ++ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) ++ ctx := context.Background() ++ ++ if _, err := rt.Create(ctx, ports.RuntimeConfig{ ++ SessionID: "sess-dup", ++ WorkspacePath: "/tmp/w", ++ Argv: []string{"sh"}, ++ }); err != nil { ++ t.Fatalf("first Create: %v", err) ++ } ++ ++ _, err := rt.Create(ctx, ports.RuntimeConfig{ ++ SessionID: "sess-dup", ++ WorkspacePath: "/tmp/w", ++ Argv: []string{"sh"}, ++ }) ++ if err == nil { ++ t.Fatal("expected error on duplicate Create, got nil") ++ } ++ if !strings.Contains(err.Error(), "already exists") { ++ t.Fatalf("error %q should contain 'already exists'", err.Error()) ++ } ++ ++ hosts["sess-dup"].cleanup(t) ++} ++ ++// TestCreate_InvalidIDErrors verifies Create rejects invalid session ids. ++func TestCreate_InvalidIDErrors(t *testing.T) { ++ isolateRegistry(t) ++ rt := New(Options{Spawner: fakeSpawnerFor(t, nil, livePID())}) ++ ctx := context.Background() ++ ++ for _, bad := range []string{"", "has space", "has/slash", "has.dot"} { ++ _, err := rt.Create(ctx, ports.RuntimeConfig{ ++ SessionID: domain.SessionID(bad), ++ WorkspacePath: "/tmp/w", ++ Argv: []string{"sh"}, ++ }) ++ if err == nil { ++ t.Fatalf("Create(%q): expected error for invalid id, got nil", bad) ++ } ++ } ++} ++ ++// TestSendMessage_DeliversChunkedTextAndEnter verifies clientSendMessage sends ++// the text + "\r" to the fakePTY input. ++func TestSendMessage_DeliversChunkedTextAndEnter(t *testing.T) { ++ isolateRegistry(t) ++ hosts := map[string]*inProcHost{} ++ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) ++ ctx := context.Background() ++ ++ handle, err := rt.Create(ctx, ports.RuntimeConfig{ ++ SessionID: "sess-sm", ++ WorkspacePath: "/tmp/w", ++ Argv: []string{"sh"}, ++ }) ++ if err != nil { ++ t.Fatalf("Create: %v", err) ++ } ++ h := hosts["sess-sm"] ++ defer h.cleanup(t) ++ ++ msg := "hello world" ++ // Collect PTY input in background. ++ inputC := make(chan []byte, 4) ++ go func() { ++ buf := make([]byte, 1024) ++ for { ++ n, err := h.pty.inR.Read(buf) ++ if n > 0 { ++ cp := make([]byte, n) ++ copy(cp, buf[:n]) ++ inputC <- cp ++ } ++ if err != nil { ++ return ++ } ++ } ++ }() ++ ++ if err := rt.SendMessage(ctx, handle, msg); err != nil { ++ t.Fatalf("SendMessage: %v", err) ++ } ++ ++ // Collect all received bytes within 2s. ++ var received []byte ++ deadline := time.After(2 * time.Second) ++ // Expect at least msg + "\r". ++ for !bytes.Contains(received, []byte("\r")) { ++ select { ++ case chunk := <-inputC: ++ received = append(received, chunk...) ++ case <-deadline: ++ t.Fatalf("timeout waiting for PTY input; got %q so far", received) ++ } ++ } ++ ++ if !bytes.HasPrefix(received, []byte(msg)) { ++ t.Fatalf("PTY input = %q, want prefix %q then \\r", received, msg) ++ } ++ if !bytes.Contains(received, []byte("\r")) { ++ t.Fatalf("PTY input = %q, missing trailing \\r", received) ++ } ++} ++ ++// TestSendMessage_LargeMessageChunked verifies a message > 512 runes is ++// delivered correctly (host receives full text + "\r"). ++func TestSendMessage_LargeMessageChunked(t *testing.T) { ++ isolateRegistry(t) ++ hosts := map[string]*inProcHost{} ++ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) ++ ctx := context.Background() ++ ++ handle, _ := rt.Create(ctx, ports.RuntimeConfig{ ++ SessionID: "sess-lg", ++ WorkspacePath: "/tmp/w", ++ Argv: []string{"sh"}, ++ }) ++ h := hosts["sess-lg"] ++ defer h.cleanup(t) ++ ++ // Build a message longer than 512 runes (use multi-byte runes to test ++ // rune-boundary splitting). ++ var sb strings.Builder ++ for i := 0; i < 600; i++ { ++ sb.WriteRune('A' + rune(i%26)) ++ } ++ msg := sb.String() ++ ++ inputDone := make(chan []byte, 1) ++ go func() { ++ // Read until we see "\r". ++ var acc []byte ++ buf := make([]byte, 4096) ++ for { ++ n, err := h.pty.inR.Read(buf) ++ if n > 0 { ++ acc = append(acc, buf[:n]...) ++ } ++ if bytes.Contains(acc, []byte("\r")) { ++ inputDone <- acc ++ return ++ } ++ if err != nil { ++ inputDone <- acc ++ return ++ } ++ } ++ }() ++ ++ if err := rt.SendMessage(ctx, handle, msg); err != nil { ++ t.Fatalf("SendMessage: %v", err) ++ } ++ ++ select { ++ case got := <-inputDone: ++ // Strip trailing \r for comparison. ++ trimmed := strings.TrimSuffix(string(got), "\r") ++ if trimmed != msg { ++ t.Fatalf("PTY received %d chars, want %d\ngot: %q\nwant: %q", len(trimmed), len(msg), trimmed[:min(50, len(trimmed))], msg[:min(50, len(msg))]) ++ } ++ case <-time.After(5 * time.Second): ++ t.Fatal("timeout waiting for large message delivery") ++ } ++} ++ ++// TestGetOutput_ReturnsRingTail verifies GetOutput returns the ring's tail. ++func TestGetOutput_ReturnsRingTail(t *testing.T) { ++ isolateRegistry(t) ++ hosts := map[string]*inProcHost{} ++ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) ++ ctx := context.Background() ++ ++ handle, _ := rt.Create(ctx, ports.RuntimeConfig{ ++ SessionID: "sess-go", ++ WorkspacePath: "/tmp/w", ++ Argv: []string{"sh"}, ++ }) ++ h := hosts["sess-go"] ++ defer h.cleanup(t) ++ ++ // Seed the ring. ++ h.ring.Append([]byte("line1\nline2\nline3\n")) ++ ++ text, err := rt.GetOutput(ctx, handle, 2) ++ if err != nil { ++ t.Fatalf("GetOutput: %v", err) ++ } ++ want := h.ring.Tail(2) ++ if text != want { ++ t.Fatalf("GetOutput = %q, want %q", text, want) ++ } ++} ++ ++// TestIsAlive_TrueWhileServing_FalseAfterClose verifies IsAlive returns true ++// while the host listens and false after its listener is closed. ++func TestIsAlive_TrueWhileServing_FalseAfterClose(t *testing.T) { ++ isolateRegistry(t) ++ hosts := map[string]*inProcHost{} ++ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) ++ ctx := context.Background() ++ ++ handle, _ := rt.Create(ctx, ports.RuntimeConfig{ ++ SessionID: "sess-ia", ++ WorkspacePath: "/tmp/w", ++ Argv: []string{"sh"}, ++ }) ++ h := hosts["sess-ia"] ++ ++ alive, err := rt.IsAlive(ctx, handle) ++ if err != nil { ++ t.Fatalf("IsAlive: %v", err) ++ } ++ if !alive { ++ t.Fatal("expected IsAlive=true while serving") ++ } ++ ++ // Shut down the host. ++ h.cancel() ++ <-h.done ++ ++ // Give the listener a moment to close. ++ time.Sleep(100 * time.Millisecond) ++ ++ alive2, err2 := rt.IsAlive(ctx, handle) ++ if err2 != nil { ++ t.Fatalf("IsAlive after close: %v", err2) ++ } ++ if alive2 { ++ t.Fatal("expected IsAlive=false after host closed") ++ } ++} ++ ++// TestIsAlive_FalseForUnknownSession verifies IsAlive returns (false, nil) for ++// a session not in the map or registry. ++func TestIsAlive_FalseForUnknownSession(t *testing.T) { ++ isolateRegistry(t) ++ rt := New(Options{Spawner: fakeSpawnerFor(t, nil, livePID())}) ++ ctx := context.Background() ++ ++ alive, err := rt.IsAlive(ctx, ports.RuntimeHandle{ID: "ghost-session"}) ++ if err != nil { ++ t.Fatalf("IsAlive: unexpected error: %v", err) ++ } ++ if alive { ++ t.Fatal("expected IsAlive=false for unknown session") ++ } ++} ++ ++// TestDestroy_KillsHostAndCleansUp verifies Destroy triggers clientKill, ++// removes the map + registry entry, and is idempotent on second call. ++// Uses deadPID() so the force-kill step is a safe no-op (the fake pty-host ++// has no real OS process; clientKill already shut it down via the loopback). ++func TestDestroy_KillsHostAndCleansUp(t *testing.T) { ++ isolateRegistry(t) ++ hosts := map[string]*inProcHost{} ++ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, deadPID())}) ++ ctx := context.Background() ++ ++ handle, err := rt.Create(ctx, ports.RuntimeConfig{ ++ SessionID: "sess-destroy", ++ WorkspacePath: "/tmp/w", ++ Argv: []string{"sh"}, ++ }) ++ if err != nil { ++ t.Fatalf("Create: %v", err) ++ } ++ h := hosts["sess-destroy"] ++ ++ // Destroy should succeed. ++ if err := rt.Destroy(ctx, handle); err != nil { ++ t.Fatalf("Destroy: %v", err) ++ } ++ ++ // Wait for Serve to stop (clientKill triggers shutdown). ++ select { ++ case <-h.done: ++ case <-time.After(3 * time.Second): ++ t.Fatal("host did not stop after Destroy") ++ } ++ ++ // fakePTY.Close must have been called. ++ h.pty.closeMu.Lock() ++ closed := h.pty.closed ++ h.pty.closeMu.Unlock() ++ if !closed { ++ t.Fatal("expected fakePTY.Close() after Destroy") ++ } ++ ++ // Map entry must be gone. ++ rt.mu.Lock() ++ _, exists := rt.sessions["sess-destroy"] ++ rt.mu.Unlock() ++ if exists { ++ t.Fatal("expected map entry removed after Destroy") ++ } ++ ++ // Registry entry must be gone. ++ entries, _ := ptyregistry.List() ++ for _, e := range entries { ++ if e.SessionID == "sess-destroy" { ++ t.Fatal("expected registry entry removed after Destroy") ++ } ++ } ++ ++ // Second Destroy must be idempotent (returns nil). ++ if err := rt.Destroy(ctx, handle); err != nil { ++ t.Fatalf("second Destroy: expected nil, got %v", err) ++ } ++} ++ ++// TestResolveViaRegistry verifies that with an empty in-memory map but a ++// registry entry pointing at a live in-process host, IsAlive and SendMessage ++// still work (simulates a daemon restart). ++func TestResolveViaRegistry(t *testing.T) { ++ isolateRegistry(t) ++ ++ // Start a host directly (not through Create) to simulate a pre-existing ++ // pty-host from a previous daemon run. Use the current process PID so ++ // ptyregistry.List() does not prune the entry as dead. ++ h := startInProcHost(t, "sess-reg", livePID()) ++ defer h.cleanup(t) ++ ++ // Manually register the host in the registry. ++ err := ptyregistry.Register(ptyregistry.Entry{ ++ SessionID: "sess-reg", ++ PtyHostPID: h.pid, ++ PipePath: h.addr, // addr stored in PipePath field ++ RegisteredAt: fmt.Sprintf("%d", time.Now().Unix()), ++ }) ++ if err != nil { ++ t.Fatalf("Register: %v", err) ++ } ++ ++ // Create a Runtime with an empty in-memory map (simulates daemon restart). ++ rt := New(Options{Spawner: fakeSpawnerFor(t, nil, livePID())}) ++ ctx := context.Background() ++ ++ // IsAlive must work via registry resolution. ++ alive, err := rt.IsAlive(ctx, ports.RuntimeHandle{ID: "sess-reg"}) ++ if err != nil { ++ t.Fatalf("IsAlive via registry: %v", err) ++ } ++ if !alive { ++ t.Fatal("expected IsAlive=true via registry resolution") ++ } ++ ++ // SendMessage must work via registry resolution. ++ inputC := make(chan []byte, 4) ++ go func() { ++ buf := make([]byte, 512) ++ for { ++ n, err := h.pty.inR.Read(buf) ++ if n > 0 { ++ cp := make([]byte, n) ++ copy(cp, buf[:n]) ++ inputC <- cp ++ } ++ if err != nil { ++ return ++ } ++ } ++ }() ++ ++ if err := rt.SendMessage(ctx, ports.RuntimeHandle{ID: "sess-reg"}, "ping"); err != nil { ++ t.Fatalf("SendMessage via registry: %v", err) ++ } ++ ++ // Collect PTY input. ++ var received []byte ++ deadline := time.After(3 * time.Second) ++ for !bytes.Contains(received, []byte("\r")) { ++ select { ++ case chunk := <-inputC: ++ received = append(received, chunk...) ++ case <-deadline: ++ t.Fatalf("timeout waiting for PTY input via registry; got %q", received) ++ } ++ } ++ if !bytes.Contains(received, []byte("ping")) { ++ t.Fatalf("PTY did not receive 'ping'; got %q", received) ++ } ++} ++ ++// --------------------------------------------------------------------------- ++// Unit tests for client helpers (dial a fresh in-proc host directly). ++// --------------------------------------------------------------------------- ++ ++// TestClientGetOutput_TimesOutReturnsEmpty verifies clientGetOutput returns "" ++// (no error) if no response arrives within the timeout. We test the happy path ++// instead (timeout path would require a non-responding server). ++func TestClientGetOutput_HappyPath(t *testing.T) { ++ f := startServe(t, 3001) ++ defer f.cancel() ++ ++ f.ring.Append([]byte("alpha\nbeta\ngamma\n")) ++ ++ text, err := clientGetOutput(f.addr, 2) ++ if err != nil { ++ t.Fatalf("clientGetOutput: %v", err) ++ } ++ want := f.ring.Tail(2) ++ if text != want { ++ t.Fatalf("clientGetOutput = %q, want %q", text, want) ++ } ++} ++ ++// TestClientIsAlive_TrueAndFalse verifies clientIsAlive returns true for a ++// live host and false for an unreachable address. ++func TestClientIsAlive_TrueAndFalse(t *testing.T) { ++ f := startServe(t, 3002) ++ defer f.cancel() ++ ++ if !clientIsAlive(f.addr) { ++ t.Fatal("clientIsAlive = false for live host, want true") ++ } ++ ++ f.cancel() ++ // Wait for listener to close. ++ select { ++ case <-f.done: ++ case <-time.After(2 * time.Second): ++ } ++ time.Sleep(50 * time.Millisecond) ++ ++ if clientIsAlive(f.addr) { ++ t.Fatal("clientIsAlive = true after host closed, want false") ++ } ++} ++ ++// TestClientKill_Idempotent verifies clientKill on a dead address returns nil. ++func TestClientKill_Idempotent(t *testing.T) { ++ if err := clientKill("127.0.0.1:1"); err != nil { ++ t.Fatalf("clientKill on unreachable addr: %v", err) ++ } ++} ++ ++// helper for TestSendMessage_LargeMessageChunked ++func min(a, b int) int { ++ if a < b { ++ return a ++ } ++ return b ++} ++ ++// Ensure the packages compile (import check). ++var _ = io.Discard +diff --git a/backend/internal/adapters/runtime/conpty/spawn.go b/backend/internal/adapters/runtime/conpty/spawn.go +new file mode 100644 +index 0000000..a32a996 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/spawn.go +@@ -0,0 +1,11 @@ ++// spawn.go - injectable hostSpawner seam. The real detached-process spawn is ++// Windows-only (spawn_windows.go). This file defines the type and the ++// defaultSpawnHost variable; the non-windows stub is in spawn_other.go. ++package conpty ++ ++import "context" ++ ++// hostSpawner starts a detached pty-host for the session and returns its ++// loopback address ("127.0.0.1:PORT") and OS pid once it prints READY. ++// Injectable for tests: replace this field on Options before calling New. ++type hostSpawner func(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (addr string, pid int, err error) +diff --git a/backend/internal/adapters/runtime/conpty/spawn_other.go b/backend/internal/adapters/runtime/conpty/spawn_other.go +new file mode 100644 +index 0000000..342836a +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/spawn_other.go +@@ -0,0 +1,16 @@ ++//go:build !windows ++ ++// spawn_other.go - stub for non-Windows platforms. The real detached-process ++// spawn lives in spawn_windows.go and uses Windows process-creation flags. ++package conpty ++ ++import ( ++ "context" ++ "errors" ++) ++ ++// defaultSpawnHost is a stub on non-Windows platforms. Tests inject their own ++// spawner; this only needs to keep the package buildable on Darwin/Linux. ++func defaultSpawnHost(_ context.Context, _, _ string, _ []string, _ map[string]string) (string, int, error) { ++ return "", 0, errors.New("conpty spawn: unsupported on this OS") ++} +diff --git a/backend/internal/adapters/runtime/conpty/spawn_windows.go b/backend/internal/adapters/runtime/conpty/spawn_windows.go +new file mode 100644 +index 0000000..e8de3d7 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/spawn_windows.go +@@ -0,0 +1,121 @@ ++//go:build windows ++ ++// spawn_windows.go - real detached pty-host spawner for Windows using ++// CREATE_NEW_PROCESS_GROUP + DETACHED_PROCESS so the host survives daemon exit. ++package conpty ++ ++import ( ++ "bufio" ++ "context" ++ "fmt" ++ "io" ++ "os" ++ "os/exec" ++ "regexp" ++ "strconv" ++ "strings" ++ "time" ++ ++ "golang.org/x/sys/windows" ++) ++ ++// readyRE matches the "READY: " line printed by RunHost. ++var readyRE = regexp.MustCompile(`READY:(\d+) (\d+)`) ++ ++const spawnReadyTimeout = 10 * time.Second ++ ++// defaultSpawnHost resolves the current executable, builds the pty-host argv, ++// and spawns it detached on Windows. It reads stdout for "READY: " ++// with a 10s timeout, then unrefs (detaches) the child. Returns the loopback ++// address and the pty-host OS PID. ++func defaultSpawnHost(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (string, int, error) { ++ exe, err := os.Executable() ++ if err != nil { ++ return "", 0, fmt.Errorf("conpty spawn: resolve executable: %w", err) ++ } ++ ++ // Build: pty-host ++ args := append([]string{"pty-host", sessionID, cwd}, argv...) ++ ++ // Merge env: inherit parent, then overlay caller-provided vars. ++ merged := os.Environ() ++ for k, v := range env { ++ merged = append(merged, k+"="+v) ++ } ++ ++ cmd := exec.CommandContext(ctx, exe, args...) ++ cmd.Dir = cwd ++ cmd.Env = merged ++ ++ // Windows process-creation flags: detached + hidden console. ++ // ponytail: DETACHED_PROCESS puts the child in its own console; without it ++ // the child is killed when the parent's console closes. CREATE_NEW_PROCESS_GROUP ++ // insulates it from Ctrl+C sent to the parent. windowsHide suppresses the flash. ++ cmd.SysProcAttr = &windows.SysProcAttr{ ++ CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP, ++ HideWindow: true, ++ } ++ ++ stdout, err := cmd.StdoutPipe() ++ if err != nil { ++ return "", 0, fmt.Errorf("conpty spawn: stdout pipe: %w", err) ++ } ++ // Stderr is discarded; pty-host writes diagnostics there but we don't need them. ++ cmd.Stderr = io.Discard ++ ++ if err := cmd.Start(); err != nil { ++ return "", 0, fmt.Errorf("conpty spawn: start: %w", err) ++ } ++ ++ // Read READY line with a timeout. ++ readyC := make(chan struct { ++ addr string ++ pid int ++ err error ++ }, 1) ++ ++ go func() { ++ scanner := bufio.NewScanner(stdout) ++ for scanner.Scan() { ++ line := strings.TrimSpace(scanner.Text()) ++ m := readyRE.FindStringSubmatch(line) ++ if m != nil { ++ pid, _ := strconv.Atoi(m[1]) ++ port, _ := strconv.Atoi(m[2]) ++ readyC <- struct { ++ addr string ++ pid int ++ err error ++ }{"127.0.0.1:" + strconv.Itoa(port), pid, nil} ++ return ++ } ++ } ++ readyC <- struct { ++ addr string ++ pid int ++ err error ++ }{"", 0, fmt.Errorf("conpty spawn: pty-host exited without printing READY")} ++ }() ++ ++ timer := time.NewTimer(spawnReadyTimeout) ++ defer timer.Stop() ++ ++ select { ++ case r := <-readyC: ++ if r.err != nil { ++ _ = cmd.Process.Kill() ++ return "", 0, r.err ++ } ++ // Unref: detach stdout so the child is not blocked, then release reference ++ // so our process can exit while the child keeps running. ++ stdout.Close() ++ cmd.Process.Release() // nolint: errcheck - best-effort detach ++ return r.addr, cmd.Process.Pid, nil ++ case <-timer.C: ++ _ = cmd.Process.Kill() ++ return "", 0, fmt.Errorf("conpty spawn: pty-host startup timeout (%s)", spawnReadyTimeout) ++ case <-ctx.Done(): ++ _ = cmd.Process.Kill() ++ return "", 0, ctx.Err() ++ } ++} + +--- Changes --- + +backend/internal/adapters/runtime/conpty/client.go + @@ -0,0 +1,179 @@ + +// client.go - loopback TCP client helpers that mirror pty-client.ts. + +// Each function dials the host addr fresh (short-lived connection) and + +// returns without maintaining state. Cross-platform: uses only stdlib net. + +package conpty + + + +import ( + + "encoding/json" + + "net" + + "time" + +) + + + +const ( + + // ptyInputChunkRunes is the max runes per terminal-input frame. + + // Mirrors PTY_INPUT_CHUNK_CHARS in pty-client.ts. + + ptyInputChunkRunes = 512 + + // ptyInputChunkDelay is the inter-chunk delay. Mirrors PTY_INPUT_CHUNK_DELAY_MS. + + ptyInputChunkDelay = 15 * time.Millisecond + + // ptyInputEnterDelay is the pause before sending Enter. Mirrors PTY_INPUT_ENTER_DELAY_MS. + + ptyInputEnterDelay = 300 * time.Millisecond + + + + dialTimeout = 3 * time.Second + + getOutputTimeout = 3 * time.Second + + isAliveTimeout = 2 * time.Second + +) + + + +// dialHost opens a TCP connection to addr with a deadline. Callers close it. + +func dialHost(addr string, timeout time.Duration) (net.Conn, error) { + + return net.DialTimeout("tcp", addr, timeout) + +} + + + +// clientSendMessage chunks message by 512 runes and sends each as a + +// MsgTerminalInput frame with 15ms gaps, then pauses 300ms and sends "\r". + +// Mirrors ptyHostSendMessage from pty-client.ts. + +func clientSendMessage(addr, message string) error { + + conn, err := dialHost(addr, dialTimeout) + + if err != nil { + + return err + + } + + defer conn.Close() + + + + runes := []rune(message) + + for i := 0; i < len(runes); i += ptyInputChunkRunes { + + end := i + ptyInputChunkRunes + + if end > len(runes) { + + end = len(runes) + + } + + chunk := string(runes[i:end]) + + frame := EncodeMessage(MsgTerminalInput, []byte(chunk)) + + if _, err := conn.Write(frame); err != nil { + + return err + + } + + // Inter-chunk delay only between chunks, not after the last one. + + if end < len(runes) { + + time.Sleep(ptyInputChunkDelay) + + } + + } + + + + // Brief pause before Enter (matches TS: Enter sent as a separate frame). + + time.Sleep(ptyInputEnterDelay) + + frame := EncodeMessage(MsgTerminalInput, []byte("\r")) + + _, err = conn.Write(frame) + + return err + +} + + + +// clientGetOutput sends MsgGetOutputReq and reads frames until MsgGetOutputRes. + +// Returns "" on timeout or connection failure (no error), matching the TS. + +// lines <= 0 is handled by the caller (runtime.go rejects it before calling). + +func clientGetOutput(addr string, lines int) (string, error) { + + conn, err := dialHost(addr, getOutputTimeout) + + if err != nil { + + return "", nil // ponytail: connect failure -> "" like the TS + + } + + defer conn.Close() + + + + _ = conn.SetDeadline(time.Now().Add(getOutputTimeout)) + + + + req, _ := json.Marshal(GetOutputReq{Lines: lines}) + + if _, err := conn.Write(EncodeMessage(MsgGetOutputReq, req)); err != nil { + + return "", nil + + } + + + + resultC := make(chan string, 1) + + parser := NewMessageParser(func(msgType byte, payload []byte) { + + if msgType == MsgGetOutputRes { + + select { + + case resultC <- string(payload): + + default: + + } + + } + + }) + + + + buf := make([]byte, 4096) + + for { + + n, err := conn.Read(buf) + + if n > 0 { + + parser.Feed(buf[:n]) + + } + + select { + + case text := <-resultC: + + return text, nil + ... (79 lines truncated) + +179 -0 + +backend/internal/adapters/runtime/conpty/pidalive_unix.go + @@ -0,0 +1,26 @@ + +//go:build !windows + + + +package conpty + + + +import ( + + "errors" + + "os" + + "syscall" + +) + + + +// pidAlive probes PID liveness via signal 0. nil and EPERM both mean alive + +// (process exists but may not be signallable). ESRCH means dead. + +// Mirrors ptyregistry.defaultPidAlive (same signal-0 pattern). + +func pidAlive(pid int) bool { + + err := syscall.Kill(pid, 0) + + if err == nil { + + return true + + } + + return errors.Is(err, syscall.EPERM) + +} + + + +// defaultOSProcessFinder wraps os.FindProcess for Unix (always succeeds on + +// Unix; the returned handle is valid for Kill). + +func defaultOSProcessFinder(pid int) (processKiller, error) { + + return os.FindProcess(pid) + +} + +26 -0 + +backend/internal/adapters/runtime/conpty/pidalive_windows.go + @@ -0,0 +1,30 @@ + +//go:build windows + + + +package conpty + + + +import ( + + "fmt" + + "os" + + + + "golang.org/x/sys/windows" + +) + + + +// pidAlive probes PID liveness on Windows by opening the process handle with + +// SYNCHRONIZE (minimal permission). Failure means the process is gone. + +func pidAlive(pid int) bool { + + h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) + + if err != nil { + + return false + + } + + _ = windows.CloseHandle(h) + + return true + +} + + + +// defaultOSProcessFinder wraps os.FindProcess for Windows. + +func defaultOSProcessFinder(pid int) (processKiller, error) { + + p, err := os.FindProcess(pid) + + if err != nil { + + return nil, fmt.Errorf("os.FindProcess(%d): %w", pid, err) + + } + + return p, nil + +} + +30 -0 + +backend/internal/adapters/runtime/conpty/runtime.go + @@ -0,0 +1,224 @@ + +// runtime.go - conpty Runtime adapter. Implements ports.Runtime (minus Attach, + +// which comes in B5). Drives sessions via the B3 pty-host over loopback TCP, + +// using the B1 protocol and the B2 registry for restart recovery. + +package conpty + + + +import ( + + "context" + + "fmt" + + "regexp" + + "sync" + + "time" + + + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + +) + + + +// Ensure Runtime satisfies the port at compile time. Attach is added in B5. + +var _ ports.Runtime = (*Runtime)(nil) + + + +// validSessionID matches agent-orchestrator's assertValidSessionId. + +var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + + + +// hostSession is the in-memory state for a live pty-host connection. + +type hostSession struct { + + addr string + + pid int + +} + + + +// Options configures the Runtime. All fields are optional; zero values use + +// sensible defaults. The Spawner field is injectable for tests. + +type Options struct { + + // Spawner overrides the default OS-level process spawner. If nil, + + // defaultSpawnHost is used (Windows-only; returns an error on other OSes). + + Spawner hostSpawner + +} + + + +// Runtime is the conpty runtime adapter. + +type Runtime struct { + + spawner hostSpawner + + + + mu sync.Mutex + + sessions map[string]*hostSession // sessionID -> live session + +} + + + +// New creates a Runtime with the given options. + +func New(opts Options) *Runtime { + + sp := opts.Spawner + + if sp == nil { + + sp = defaultSpawnHost + + } + + return &Runtime{ + + spawner: sp, + + sessions: make(map[string]*hostSession), + + } + +} + + + +// Create spawns a detached pty-host for the session, waits for READY, stores + +// the addr+pid in-memory and in the B2 registry, and returns the handle. + +// Returns an error if sessionID is invalid, already exists, or spawn fails. + +func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { + + id := string(cfg.SessionID) + + if !validSessionID.MatchString(id) { + + return ports.RuntimeHandle{}, fmt.Errorf("conpty: invalid session id %q: must match ^[a-zA-Z0-9_-]+$", id) + + } + + if cfg.WorkspacePath == "" { + + return ports.RuntimeHandle{}, fmt.Errorf("conpty: workspace path required") + + } + + if len(cfg.Argv) == 0 { + + return ports.RuntimeHandle{}, fmt.Errorf("conpty: argv required") + + } + + + + r.mu.Lock() + + if _, dup := r.sessions[id]; dup { + + r.mu.Unlock() + + return ports.RuntimeHandle{}, fmt.Errorf("conpty: session %q already exists; destroy before re-creating", id) + + } + + // Reserve the slot before the async spawn so a concurrent Create for the + + // same id fails immediately (no gap between check and set). + + r.sessions[id] = nil + + r.mu.Unlock() + + + + addr, pid, err := r.spawner(ctx, id, cfg.WorkspacePath, cfg.Argv, cfg.Env) + + if err != nil { + + r.mu.Lock() + + delete(r.sessions, id) + + r.mu.Unlock() + + return ports.RuntimeHandle{}, fmt.Errorf("conpty: spawn pty-host for %q: %w", id, err) + + } + + + + sess := &hostSession{addr: addr, pid: pid} + + + + r.mu.Lock() + + r.sessions[id] = sess + + r.mu.Unlock() + + + + // Register in B2 registry for daemon-restart recovery (best-effort). + + _ = ptyregistry.Register(ptyregistry.Entry{ + + SessionID: id, + + PtyHostPID: pid, + + PipePath: addr, // ponytail: reuse PipePath field for loopback addr + ... (124 lines truncated) + +224 -0 + +backend/internal/adapters/runtime/conpty/runtime_test.go + @@ -0,0 +1,612 @@ + +package conpty + + + +import ( + + "bytes" + + "context" + + "fmt" + + "io" + + "net" + + "os" + + "strings" + + "testing" + + "time" + + + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + +) + + + +// livePID returns a PID that is guaranteed to be alive (the current process). + +// Using this as the fake pty-host PID means ptyregistry.List() will not prune + +// the entry during tests. Do NOT use this for the Destroy test: Destroy calls + +// Kill on the pid, so use deadPID() there instead. + +func livePID() int { return os.Getpid() } + + + +// deadPID returns a PID that is guaranteed to be dead (no process). This is + +// used in Destroy tests so the force-kill step is a safe no-op. + +// ponytail: PID 2147483647 (MaxInt32) is never a real process; signal-0 returns ESRCH. + +func deadPID() int { return 2147483647 } + + + +// --------------------------------------------------------------------------- + +// Test harness: in-process pty-host backed by a fakePTY. + +// --------------------------------------------------------------------------- + + + +// inProcHost starts a Serve engine with a fakePTY on a real 127.0.0.1:0 + +// listener and returns a fake spawner that returns that addr and a fake pid. + +// The caller must call cleanup() to shut down the host. + +type inProcHost struct { + + addr string + + pid int + + pty *fakePTY + + ring *Ring + + cancel context.CancelFunc + + done chan error + + ln net.Listener + +} + + + +func startInProcHost(t *testing.T, sessionID string, fakePID int) *inProcHost { + + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + + if err != nil { + + t.Fatalf("listen: %v", err) + + } + + pty := newFakePTY(fakePID) + + ring := NewRing() + + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan error, 1) + + go func() { + + done <- Serve(ctx, ServeConfig{ + + SessionID: sessionID, + + Listener: ln, + + PTY: pty, + + Ring: ring, + + }) + + }() + + return &inProcHost{ + + addr: ln.Addr().String(), + + pid: fakePID, + + pty: pty, + + ring: ring, + + cancel: cancel, + + done: done, + + ln: ln, + + } + +} + + + +func (h *inProcHost) cleanup(t *testing.T) { + + t.Helper() + + h.cancel() + + select { + + case <-h.done: + + case <-time.After(2 * time.Second): + + t.Log("warning: inProcHost did not stop within 2s") + + } + +} + + + +// fakeSpawnerFor returns a hostSpawner that starts an in-process host for a + +// single session ID and records which sessions have been spawned. + +// The returned map maps sessionID -> *inProcHost for test inspection. + +func fakeSpawnerFor(t *testing.T, hosts map[string]*inProcHost, fakePID int) hostSpawner { + + t.Helper() + + return func(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (string, int, error) { + + h := startInProcHost(t, sessionID, fakePID) + + if hosts != nil { + + hosts[sessionID] = h + + } + + return h.addr, h.pid, nil + + } + +} + + + +// --------------------------------------------------------------------------- + ... (512 lines truncated) + +612 -0 + +backend/internal/adapters/runtime/conpty/spawn.go + @@ -0,0 +1,11 @@ + +// spawn.go - injectable hostSpawner seam. The real detached-process spawn is + +// Windows-only (spawn_windows.go). This file defines the type and the + +// defaultSpawnHost variable; the non-windows stub is in spawn_other.go. + +package conpty + + + +import "context" + + + +// hostSpawner starts a detached pty-host for the session and returns its + +// loopback address ("127.0.0.1:PORT") and OS pid once it prints READY. + +// Injectable for tests: replace this field on Options before calling New. + +type hostSpawner func(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (addr string, pid int, err error) + +11 -0 + +backend/internal/adapters/runtime/conpty/spawn_other.go + @@ -0,0 +1,16 @@ + +//go:build !windows + + + +// spawn_other.go - stub for non-Windows platforms. The real detached-process + +// spawn lives in spawn_windows.go and uses Windows process-creation flags. + +package conpty + + + +import ( + + "context" + + "errors" + +) + + + +// defaultSpawnHost is a stub on non-Windows platforms. Tests inject their own + +// spawner; this only needs to keep the package buildable on Darwin/Linux. + +func defaultSpawnHost(_ context.Context, _, _ string, _ []string, _ map[string]string) (string, int, error) { + + return "", 0, errors.New("conpty spawn: unsupported on this OS") + +} + +16 -0 + +backend/internal/adapters/runtime/conpty/spawn_windows.go + @@ -0,0 +1,121 @@ + +//go:build windows + + + +// spawn_windows.go - real detached pty-host spawner for Windows using + +// CREATE_NEW_PROCESS_GROUP + DETACHED_PROCESS so the host survives daemon exit. + +package conpty + + + +import ( + + "bufio" + + "context" + + "fmt" + + "io" + + "os" + + "os/exec" + + "regexp" + + "strconv" + + "strings" + + "time" + + + + "golang.org/x/sys/windows" + +) + + + +// readyRE matches the "READY: " line printed by RunHost. + +var readyRE = regexp.MustCompile(`READY:(\d+) (\d+)`) + + + +const spawnReadyTimeout = 10 * time.Second + + + +// defaultSpawnHost resolves the current executable, builds the pty-host argv, + +// and spawns it detached on Windows. It reads stdout for "READY: " + +// with a 10s timeout, then unrefs (detaches) the child. Returns the loopback + +// address and the pty-host OS PID. + +func defaultSpawnHost(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (string, int, error) { + + exe, err := os.Executable() + + if err != nil { + + return "", 0, fmt.Errorf("conpty spawn: resolve executable: %w", err) + + } + + + + // Build: pty-host + + args := append([]string{"pty-host", sessionID, cwd}, argv...) + + + + // Merge env: inherit parent, then overlay caller-provided vars. + + merged := os.Environ() + + for k, v := range env { + + merged = append(merged, k+"="+v) + + } + + + + cmd := exec.CommandContext(ctx, exe, args...) + + cmd.Dir = cwd + + cmd.Env = merged + + + + // Windows process-creation flags: detached + hidden console. + + // ponytail: DETACHED_PROCESS puts the child in its own console; without it + + // the child is killed when the parent's console closes. CREATE_NEW_PROCESS_GROUP + + // insulates it from Ctrl+C sent to the parent. windowsHide suppresses the flash. + + cmd.SysProcAttr = &windows.SysProcAttr{ + + CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP, + + HideWindow: true, + + } + + + + stdout, err := cmd.StdoutPipe() + + if err != nil { + + return "", 0, fmt.Errorf("conpty spawn: stdout pipe: %w", err) + + } + + // Stderr is discarded; pty-host writes diagnostics there but we don't need them. + + cmd.Stderr = io.Discard + + + + if err := cmd.Start(); err != nil { + + return "", 0, fmt.Errorf("conpty spawn: start: %w", err) + + } + + + + // Read READY line with a timeout. + + readyC := make(chan struct { + + addr string + + pid int + + err error + + }, 1) + + + + go func() { + + scanner := bufio.NewScanner(stdout) + + for scanner.Scan() { + + line := strings.TrimSpace(scanner.Text()) + + m := readyRE.FindStringSubmatch(line) + + if m != nil { + + pid, _ := strconv.Atoi(m[1]) + + port, _ := strconv.Atoi(m[2]) + + readyC <- struct { + + addr string + + pid int + + err error + + }{"127.0.0.1:" + strconv.Itoa(port), pid, nil} + + return + + } + +... (more changes truncated) + +91 -0 +[full diff: rtk git diff --no-compact] diff --git a/.superpowers/sdd/task-b5-brief.md b/.superpowers/sdd/task-b5-brief.md new file mode 100644 index 00000000..9fdf7437 --- /dev/null +++ b/.superpowers/sdd/task-b5-brief.md @@ -0,0 +1,159 @@ +# Task B5: terminal Attach/Stream interface change + tmux/zellij/conpty Attach + +## Goal +Evolve the terminal layer from argv-based attach (`PTYSource.AttachCommand` + a +`spawnFunc`) to stream-based attach (`Attacher.Attach(...) Stream`), so the conpty +runtime can attach by dialing its loopback host directly (no argv to exec) while +tmux/zellij keep spawning their attach CLI under the hood. This is the keystone that +makes conpty usable from the dashboard. It is fully testable on this Darwin machine +(terminal unit tests + tmux integration). Keep the build green and ALL tests +passing. This is a refactor of working code: be surgical and preserve behavior on +the tmux/zellij paths exactly. + +Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, +module `backend/`, branch `migrate-zellij-to-tmux-conpty`. Module prefix +`github.com/aoagents/agent-orchestrator/backend`. + +## Read first (the code you are changing) +- `internal/terminal/attachment.go` (the run loop: IsAlive gate -> AttachCommand -> + size -> spawn -> copyOut -> reattach), `manager.go` (spawn/WithSpawn plumbing, + openTerminal), `pty_unix.go` + `pty_windows.go` (defaultSpawn + creackPTY / + conPTYProcess), `fakes_test.go` (fakeSource, fakeSpawner), `attachment_test.go`, + `manager_test.go`, `attachment_integration_test.go`, `logger_test.go`, + `pty_unix_test.go`, `pty_windows_test.go`. +- `internal/ports/outbound.go` (Runtime, RuntimeHandle). +- `internal/adapters/runtime/tmux/` (AttachCommand), `.../zellij/` (AttachCommand), + `.../conpty/` (client.go dial + proto), `.../runtimeselect/select.go` (the union). + +## Step 1 — define Stream + Attacher in `ports` (`internal/ports/outbound.go`) +```go +// Stream is one live terminal attach: PTY-like bytes plus resize. Returned +// already-open by a Runtime's Attach. tmux/zellij back it with a local PTY around +// their attach CLI; conpty backs it with a loopback connection to the pty-host. +type Stream interface { + io.ReadWriteCloser + Resize(rows, cols uint16) error +} + +// Attacher opens a fresh attach Stream for a session handle, sized rows x cols from +// birth (0 means size not yet known). ctx cancellation must terminate the stream. +type Attacher interface { + Attach(ctx context.Context, handle RuntimeHandle, rows, cols uint16) (Stream, error) +} +``` +(`io` import added to ports.) Do NOT remove `Runtime`/`RuntimeHandle`. + +## Step 2 — extract the PTY spawn into a shared package `internal/adapters/runtime/ptyexec` +Move the existing `defaultSpawn` + `creackPTY` (from terminal/pty_unix.go) and the +ConPTY `conPTYProcess` (from terminal/pty_windows.go) into a new package `ptyexec`, +exported as: +```go +// Spawn starts argv on a local PTY (creack/pty on unix, go-pty ConPTY on windows), +// sized rows x cols from birth when known. env, when non-nil, replaces the inherited +// environment. ctx cancellation closes the PTY via the same graceful path as Close. +func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) +``` +- Preserve EVERY behavior and comment from the originals: StartWithSize, the + SIGWINCH-after-Setsize self-heal on unix, the SIGTERM->grace->SIGKILL detach on + unix (`detachGrace`), the ConPTY EOF-then-Kill close on windows. The returned + concrete types must satisfy `ports.Stream` (they already have Read/Write/Close + + Resize). Keep the unix/windows build-tag split (`spawn_unix.go`/`spawn_windows.go`). +- Move pty_unix_test.go / pty_windows_test.go into ptyexec as well (adjust package + + any unexported references). These tests must still pass. +- The terminal package no longer needs defaultSpawn after Step 3; delete the moved + files from terminal. + +## Step 3 — rewire the terminal layer to Attach (`attachment.go`, `manager.go`) +- Replace `PTYSource` with: +```go +// Source is what the terminal needs from the runtime: open an attach Stream and a +// liveness check used to decide whether a dropped Stream should be re-attached or +// treated as a clean exit. +type Source interface { + ports.Attacher + IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) +} +``` + (Rename PTYSource -> Source throughout terminal, or keep the name PTYSource but + change its methods; pick one and be consistent. Update doc comments that describe + "argv".) +- In `attachment`: delete the `spawn spawnFunc` field and the `spawnFunc` type. + Change the run loop: after the IsAlive gate, do + `rows, cols := a.size(); p, err := a.src.Attach(ctx, a.handle, rows, cols)` + instead of AttachCommand + spawn. `p` is a `ports.Stream`; the rest of the loop + (setPTY/copyOut/clearPTY/Close/reattach/backoff) is UNCHANGED. The local + `ptyProcess` interface in attachment.go can be replaced by `ports.Stream` (same + shape) or kept as an alias; prefer using `ports.Stream` directly. + Keep the failure handling: an Attach error increments failures and backs off (same + as the old spawn error path); a definitive `!alive` still markExited. +- In `manager.go`: delete `spawn spawnFunc`, the `defaultSpawn` default, and + `WithSpawn`. `newAttachment` loses its spawn parameter. `NewManager(src, events, + log, opts...)` keeps `src` (now a `Source`). If tests need to inject a fake, they + inject it via `src` (already the first arg) — so `WithSpawn` is removed and tests + pass a fake `Source` whose `Attach` returns a fake `Stream`. + +## Step 4 — implement Attach on the three adapters +- **tmux** (`internal/adapters/runtime/tmux`): add + `func (r *Runtime) Attach(ctx, handle, rows, cols) (ports.Stream, error)`: + reuse the existing argv builder (the body of AttachCommand: `tmux attach-session + -t `), then `return ptyexec.Spawn(ctx, argv, nil, rows, cols)`. You MAY keep + AttachCommand as an unexported helper that builds the argv, or inline it. Add + `var _ ports.Attacher = (*Runtime)(nil)`. Keep AttachCommand removed from the + public surface only if nothing else uses it (the CLI/doctor do not). +- **zellij** (`internal/adapters/runtime/zellij`): same pattern. zellij's + AttachCommand returns argv AND an env block (Windows ConPTY socket dir); pass both + to `ptyexec.Spawn(ctx, argv, env, rows, cols)`. Add the `var _ ports.Attacher` + assertion. (zellij stays in the tree; Windows still builds it until B6.) +- **conpty** (`internal/adapters/runtime/conpty`): add + `func (r *Runtime) Attach(ctx, handle, rows, cols) (ports.Stream, error)`: + resolve the session addr (the existing resolve()), `dialHost(addr, ...)`, send an + initial MsgResize if rows/cols > 0, and return a `*loopbackStream` that: + - runs a goroutine reading frames via a B1 MessageParser; for each MsgTerminalData + it writes the payload into an internal io.Pipe; `Read` drains that pipe. (The + host sends the scrollback Snapshot as the first MsgTerminalData on connect, so + Read naturally yields the replay first.) + - `Write(p)` sends `EncodeMessage(MsgTerminalInput, p)` to the conn. + - `Resize(rows, cols)` sends `EncodeMessage(MsgResize, json{cols,rows})`. + - `Close()` closes the conn and the pipe; ctx cancellation also closes it. + Add `var _ ports.Attacher = (*Runtime)(nil)`. This is fully testable on Darwin + against an in-process B3 Serve + fakePTY: connect, assert the scrollback replay + arrives on Read, Write reaches the fakePTY input, Resize reaches fakePTY.Resize. + +## Step 5 — update the runtimeselect union (`internal/adapters/runtime/runtimeselect/select.go`) +Replace `AttachCommand(handle) ([]string, []string, error)` in the union `Runtime` +interface with `ports.Attacher` (i.e. `Attach(...)`). Keep the compile-time +assertions for tmux and zellij; they now also assert Attach. The daemon wiring +(terminal.NewManager(runtimeAdapter, ...)) still compiles because the union +satisfies terminal.Source. + +## Step 6 — migrate the terminal tests +- `fakes_test.go`: change `fakeSource` to implement `Attach(ctx, handle, rows, cols) + (ports.Stream, error)` (returning a scripted fake Stream) + IsAlive. Replace + `fakeSpawner`/fake spawn with a fake `Stream` (in-memory Read/Write/Resize/Close, + scriptable like the old fake PTY). Preserve the existing `alive/aliveErr/attachErr` + knobs (attachErr now makes Attach fail). +- `attachment_test.go`, `manager_test.go`, `logger_test.go`, + `attachment_integration_test.go`: update `newAttachment(...)`/`NewManager(...)` + call sites to drop the spawn arg / WithSpawn, and to use the fake Source's Attach. + The integration test (which used the real defaultSpawn + a real runtime) should now + drive a real adapter's Attach (tmux is available; or keep it pointed at whatever + runtime it used, adapted to Attach). Keep every assertion's intent. Do not weaken + tests to pass; if a test asserted argv contents from AttachCommand, re-target it to + assert the adapter's argv via the adapter's own (possibly unexported) builder or + drop only that now-meaningless assertion, noting why. + +## Definition of done (from `backend/`) +- `go build ./...` ; `GOOS=windows go build ./...` ; `GOOS=linux go build ./...` succeed. +- `go test -race ./...` passes (the WHOLE backend suite, not just the changed pkgs). +- `go vet ./...` clean. +- tmux integration test still passes against real tmux 3.6b (the Darwin attach path + must still actually work end to end). + +## Hard rules +- This refactors verified, working code (the tmux attach path shipped in Phase A). + Preserve its behavior exactly; the only structural change is argv->Stream. Do not + alter the reattach/backoff/size/SIGWINCH/detach semantics. +- No new go.mod module. Never use em dashes ("—"). Minimal/idiomatic; mark shortcuts + with `ponytail:`. Update stale "Zellij"/"argv" comments in the terminal files you + touch to be runtime-neutral. +- Commit on the branch; trailer `Co-Authored-By: Claude Opus 4.8 `. From 5bfbc489ccdfdb2fa9caff7471e9a96487d2c8fd Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 24 Jun 2026 00:25:36 +0530 Subject: [PATCH 16/60] feat(terminal): stream-based Attach for tmux/zellij/conpty Evolve the terminal layer from argv-based attach (PTYSource.AttachCommand + injected spawnFunc) to stream-based attach (Source embedding ports.Attacher). tmux/zellij keep spawning their attach CLI on a local PTY via the new shared ptyexec.Spawn; conpty attaches by dialing its loopback pty-host directly with a loopbackStream over the B1 framing protocol. Reattach/backoff/size/SIGWINCH/detach semantics are unchanged. - ports: add Stream + Attacher. - ptyexec: new shared package holding the creack/pty (unix) and ConPTY (windows) spawn, moved verbatim from terminal with its tests. - terminal: PTYSource -> Source, drop spawnFunc/WithSpawn, run loop calls src.Attach and uses ports.Stream. - tmux/zellij: add Attach (argv via ptyexec.Spawn); conpty: add Attach (loopbackStream); ports.Attacher assertions on all three. - runtimeselect: union embeds ports.Attacher in place of AttachCommand. - tests migrated; new conpty attach_test against in-process Serve+fakePTY. Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/task-b5-report.md | 146 +++++++++++++++++ .../adapters/runtime/conpty/attach.go | 116 ++++++++++++++ .../adapters/runtime/conpty/attach_test.go | 151 ++++++++++++++++++ .../adapters/runtime/conpty/runtime.go | 8 +- .../runtime/ptyexec/spawn_unix.go} | 37 +++-- .../runtime/ptyexec/spawn_unix_test.go} | 18 +-- .../runtime/ptyexec/spawn_windows.go} | 21 +-- .../runtime/ptyexec/spawn_windows_test.go} | 20 +-- .../runtime/runtimeselect/runtimeselect.go | 5 +- .../internal/adapters/runtime/tmux/tmux.go | 17 +- .../adapters/runtime/tmux/tmux_test.go | 4 +- .../adapters/runtime/zellij/zellij.go | 18 ++- .../adapters/runtime/zellij/zellij_test.go | 4 +- backend/internal/httpd/terminal_mux_test.go | 13 +- backend/internal/ports/outbound.go | 15 ++ backend/internal/terminal/attachment.go | 98 ++++-------- .../terminal/attachment_integration_test.go | 4 +- backend/internal/terminal/attachment_test.go | 112 ++++++------- backend/internal/terminal/fakes_test.go | 29 ++-- backend/internal/terminal/logger_test.go | 4 +- backend/internal/terminal/manager.go | 25 ++- backend/internal/terminal/manager_test.go | 54 +++---- 22 files changed, 677 insertions(+), 242 deletions(-) create mode 100644 .superpowers/sdd/task-b5-report.md create mode 100644 backend/internal/adapters/runtime/conpty/attach.go create mode 100644 backend/internal/adapters/runtime/conpty/attach_test.go rename backend/internal/{terminal/pty_unix.go => adapters/runtime/ptyexec/spawn_unix.go} (66%) rename backend/internal/{terminal/pty_unix_test.go => adapters/runtime/ptyexec/spawn_unix_test.go} (89%) rename backend/internal/{terminal/pty_windows.go => adapters/runtime/ptyexec/spawn_windows.go} (70%) rename backend/internal/{terminal/pty_windows_test.go => adapters/runtime/ptyexec/spawn_windows_test.go} (73%) diff --git a/.superpowers/sdd/task-b5-report.md b/.superpowers/sdd/task-b5-report.md new file mode 100644 index 00000000..30826f81 --- /dev/null +++ b/.superpowers/sdd/task-b5-report.md @@ -0,0 +1,146 @@ +# Task B5 Report: terminal Attach/Stream interface change + +Status: DONE + +## Summary +Evolved the terminal layer from argv-based attach (`PTYSource.AttachCommand` + +injected `spawnFunc`) to stream-based attach (`Source` embedding +`ports.Attacher`). tmux/zellij keep spawning their attach CLI on a local PTY +(now via the shared `ptyexec.Spawn`); conpty attaches by dialing its loopback +pty-host directly. Behavior on the tmux/zellij paths is preserved exactly. + +## Interface shapes + +`internal/ports/outbound.go` (added `io` import): +```go +type Stream interface { + io.ReadWriteCloser + Resize(rows, cols uint16) error +} +type Attacher interface { + Attach(ctx context.Context, handle RuntimeHandle, rows, cols uint16) (Stream, error) +} +``` + +`internal/terminal/attachment.go`: +```go +type Source interface { + ports.Attacher + IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) +} +``` +`Runtime`/`RuntimeHandle` untouched. + +## Files changed / moved + +### New shared package `internal/adapters/runtime/ptyexec` +- `spawn_unix.go` — `Spawn(ctx, argv, env, rows, cols) (ports.Stream, error)`, + the verbatim creack/pty path: StartWithSize, the Setsize+explicit-SIGWINCH + self-heal, the SIGTERM->detachGrace->SIGKILL idempotent Close, ctx-cancel + closes the PTY. Concrete type `creackPTY` satisfies `ports.Stream`. +- `spawn_windows.go` — verbatim ConPTY path (`conPTYProcess`): Resize-on-birth, + EOF-then-Kill Close. Same `Spawn` signature, build-tag split preserved. +- `spawn_unix_test.go` / `spawn_windows_test.go` — moved from terminal, + re-pointed at `Spawn`/`detachGrace`/`ports.Stream`. Generic "attach client" + wording replaces "zellij" in the moved comments. (Windows test renamed + `TestDefaultSpawnWindows*` -> `TestSpawnWindows*`.) + +### Deleted from terminal +`pty_unix.go`, `pty_windows.go`, `pty_unix_test.go`, `pty_windows_test.go` +(moved into ptyexec). + +### `internal/ports/outbound.go` +Added `Stream` + `Attacher` + `io` import. + +### `internal/terminal/attachment.go` +- `PTYSource` -> `Source` (embeds `ports.Attacher` + IsAlive). +- Deleted the `ptyProcess` interface and the `spawnFunc` type; the run loop and + helpers (`copyOut`/`setPTY`/`clearPTY`) now use `ports.Stream` directly. +- Run loop: dropped the `AttachCommand` step; after the IsAlive gate it does + `rows, cols := a.size(); p, err := a.src.Attach(ctx, a.handle, rows, cols)`. + Reattach/backoff/size/markExited semantics are byte-for-byte identical. An + Attach error now shares the spawn-error retry policy (increment failures, back + off, cap at maxReattach) — the old immediate-fail on AttachCommand error is + gone because there is no separate argv-build step anymore. +- Stale "Zellij"/"argv" comments made runtime-neutral. + +### `internal/terminal/manager.go` +Removed `spawn spawnFunc` field, the `defaultSpawn` default, and `WithSpawn`. +`newAttachment` lost its spawn parameter. `src` is now a `Source`. Comments made +runtime-neutral. + +### tmux (`internal/adapters/runtime/tmux/tmux.go`) +`AttachCommand` -> unexported `attachCommand` (argv builder unchanged); new +`Attach(ctx, handle, rows, cols)` = `ptyexec.Spawn(ctx, argv, nil, rows, cols)`. +Added `var _ ports.Attacher = (*Runtime)(nil)` and the ptyexec import. + +### zellij (`internal/adapters/runtime/zellij/zellij.go`) +Same pattern; `attachCommand` returns argv AND the Windows env block, both passed +to `ptyexec.Spawn`. Added the `ports.Attacher` assertion + ptyexec import. + +### conpty (`internal/adapters/runtime/conpty/attach.go`, new) +`Attach` resolves the session addr (existing `resolve()`), `dialHost`, sends an +initial `MsgResize` when rows/cols > 0, and returns a `*loopbackStream`: +- a `pump` goroutine feeds a `MessageParser`; each `MsgTerminalData` payload is + written into an `io.Pipe` (the host sends the scrollback Snapshot as the first + such frame, so `Read` yields the replay first). Pipe back-pressure preserves + order; conn EOF closes the pipe with the error. +- `Write(p)` -> `EncodeMessage(MsgTerminalInput, p)` on the conn. +- `Resize(rows, cols)` -> `EncodeMessage(MsgResize, json{cols,rows})`. +- `Close()` (sync.Once) closes conn + both pipe ends; a ctx goroutine calls it on + cancel. +`runtime.go` header/assertion comments de-staled ("Attach in attach.go"). + +### runtimeselect (`runtimeselect.go`) +Union `Runtime` now embeds `ports.Attacher` in place of the `AttachCommand` +method; tmux + zellij compile-time assertions still hold (they now also assert +Attach). Daemon wiring `terminal.NewManager(runtimeAdapter, ...)` still compiles +because the union satisfies `terminal.Source`. + +## Test migration + +- `fakes_test.go`: `fakeSource` now implements `Attach` (delegating to an + embedded `*fakeSpawner` or a custom `attachFn` closure) + IsAlive; the + `alive/aliveErr/attachErr` knobs are preserved (`attachErr` makes Attach fail). + `fakeSpawner.spawn` lost its argv/env/ctx params (now `spawn(rows, cols) + (ports.Stream, error)`); `fakePTY` is a `ports.Stream`. The `argv` knob was + dropped (argv is no longer surfaced to the terminal layer). +- `attachment_test.go`: helpers lost the spawn arg; each test sets + `src.spawner = sp` (or `src.attachFn = ...`) instead of passing `sp.spawn`. + `TestAttachmentFailsWhenAttachCommandErrors` -> `TestAttachmentFailsWhenAttachErrors`: + retargeted to assert a persistent Attach error backs off and exits after the + retry budget (cap lowered to keep it fast), since attach errors now share the + spawn-error retry path. Every other assertion's intent preserved; "spawns" + wording -> "attaches". +- `manager_test.go`: dropped `WithSpawn`; fakes wired via `src.spawner`/`attachFn`. + Added the `ports` import. zellij-specific comments made runtime-neutral. +- `logger_test.go`: dropped the spawn arg from `newAttachment`/`NewManager`. +- `attachment_integration_test.go`: still drives the REAL zellij runtime, now via + its `Attach` (dropped the explicit `defaultSpawn` arg — `*zellij.Runtime` + satisfies `terminal.Source`). Alt-screen + stty-size assertions unchanged. +- `httpd/terminal_mux_test.go`: `stubSource.AttachCommand` -> `Attach` calling + `ptyexec.Spawn` (still exercises the genuine creack/pty + wsjson + Serve flow + on Darwin). +- New `conpty/attach_test.go`: drives `Attach` against an in-process B3 `Serve` + + `fakePTY` (reusing host_test.go's `serveFixture`): scrollback replay arrives on + Read; Write reaches `fakePTY.inR`; birth + explicit Resize reach + `fakePTY.Resize`; unknown session errors. 4 tests. + +## Verification (from backend/) +- `go build ./...` — Success +- `GOOS=windows go build ./...` — Success +- `GOOS=linux go build ./...` — Success +- `go vet ./...` — No issues found +- `go test -race ./...` — 1638 passed in 78 packages, 0 failures +- Real integration (not skipped, ran under -race): tmux 3.6b + `TestRuntimeIntegration` + `TestRuntimeIntegrationExactSessionParsing` pass; + zellij-backed `TestAttachmentStreamsRealZellijPane` + + `TestAttachmentReattachAdoptsNewSize` pass (the Darwin attach path works end to + end through the new `Attach`). + +## Concerns +None blocking. One intentional behavior note: a failed Attach is now treated as a +transient/retryable failure (back off + cap) rather than an immediate fail, +because the argv build is folded into Attach. This is the correct semantics for a +dial/exec failure and matches the old spawn-error path; the one test asserting +the old immediate-fail was retargeted accordingly. diff --git a/backend/internal/adapters/runtime/conpty/attach.go b/backend/internal/adapters/runtime/conpty/attach.go new file mode 100644 index 00000000..c2e83632 --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/attach.go @@ -0,0 +1,116 @@ +// attach.go - conpty Attach: a loopback Stream over the B3 pty-host. Unlike +// tmux/zellij, conpty does not spawn an attach CLI; it dials the session's +// loopback host and speaks the B1 framing protocol directly. The host replays +// the scrollback Snapshot as the first MsgTerminalData on connect, so a fresh +// Read naturally yields the repaint first. +package conpty + +import ( + "context" + "encoding/json" + "fmt" + "io" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.Attacher = (*Runtime)(nil) + +// Attach opens a fresh attach Stream for the session by dialing its loopback +// pty-host. rows/cols size the host's PTY from birth when known (a MsgResize is +// sent right after connect). ctx cancellation closes the Stream. +func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { + sess := r.resolve(handle.ID) + if sess == nil { + return nil, fmt.Errorf("conpty: session %q not found", handle.ID) + } + conn, err := dialHost(sess.addr, dialTimeout) + if err != nil { + return nil, fmt.Errorf("conpty: dial host for %q: %w", handle.ID, err) + } + + pr, pw := io.Pipe() + s := &loopbackStream{conn: conn, pr: pr, pw: pw} + + // Pump host frames: MsgTerminalData payloads go into the pipe that Read + // drains. The first such frame is the scrollback snapshot, so the replay + // arrives before any live output. + go s.pump() + + // ctx cancellation must terminate the stream (mirrors the unix/windows + // spawn paths closing the PTY on ctx.Done). + go func() { + <-ctx.Done() + _ = s.Close() + }() + + if rows > 0 && cols > 0 { + if err := s.Resize(rows, cols); err != nil { + _ = s.Close() + return nil, err + } + } + return s, nil +} + +// loopbackStream is a ports.Stream backed by a single loopback connection to the +// pty-host. The pump goroutine reframes host output into an io.Pipe so Read +// presents a plain byte stream; Write/Resize encode client frames onto the conn. +type loopbackStream struct { + conn io.ReadWriteCloser + pr *io.PipeReader + pw *io.PipeWriter + + closeOnce sync.Once +} + +// pump reads framed host messages and writes MsgTerminalData payloads into the +// pipe. It closes the pipe when the connection ends so Read returns EOF. +func (s *loopbackStream) pump() { + parser := NewMessageParser(func(msgType byte, payload []byte) { + if msgType == MsgTerminalData { + // Write blocks until Read drains, preserving back-pressure and order. + _, _ = s.pw.Write(payload) + } + }) + buf := make([]byte, 4096) + for { + n, err := s.conn.Read(buf) + if n > 0 { + parser.Feed(buf[:n]) + } + if err != nil { + _ = s.pw.CloseWithError(err) + return + } + } +} + +func (s *loopbackStream) Read(p []byte) (int, error) { return s.pr.Read(p) } + +func (s *loopbackStream) Write(p []byte) (int, error) { + if _, err := s.conn.Write(EncodeMessage(MsgTerminalInput, p)); err != nil { + return 0, err + } + return len(p), nil +} + +func (s *loopbackStream) Resize(rows, cols uint16) error { + payload, _ := json.Marshal(ResizePayload{Cols: int(cols), Rows: int(rows)}) + _, err := s.conn.Write(EncodeMessage(MsgResize, payload)) + return err +} + +// Close closes the conn and the pipe. Idempotent. Closing the conn unblocks +// pump's Read, which then closes the pipe-writer too; closing both here makes +// Close safe to call directly (e.g. on ctx cancel) without waiting for pump. +func (s *loopbackStream) Close() error { + var err error + s.closeOnce.Do(func() { + err = s.conn.Close() + _ = s.pw.Close() + _ = s.pr.Close() + }) + return err +} diff --git a/backend/internal/adapters/runtime/conpty/attach_test.go b/backend/internal/adapters/runtime/conpty/attach_test.go new file mode 100644 index 00000000..08b7ff89 --- /dev/null +++ b/backend/internal/adapters/runtime/conpty/attach_test.go @@ -0,0 +1,151 @@ +package conpty + +import ( + "bytes" + "context" + "io" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// runtimeForFixture wires a conpty Runtime to a running serveFixture by stuffing +// the fixture's loopback addr into the session map under the given id, so Attach +// resolves it without a real Windows spawn. +func runtimeForFixture(id string, f *serveFixture) *Runtime { + r := New(Options{}) + r.mu.Lock() + r.sessions[id] = &hostSession{addr: f.addr, pid: f.pty.PID()} + r.mu.Unlock() + return r +} + +func readUntil(t *testing.T, s io.Reader, want string, timeout time.Duration) string { + t.Helper() + type res struct { + out string + } + done := make(chan res, 1) + go func() { + var buf []byte + tmp := make([]byte, 4096) + for { + n, err := s.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + if bytes.Contains(buf, []byte(want)) { + done <- res{string(buf)} + return + } + } + if err != nil { + done <- res{string(buf)} + return + } + } + }() + select { + case r := <-done: + return r.out + case <-time.After(timeout): + t.Fatalf("timed out reading for %q", want) + return "" + } +} + +// TestAttachReplaysScrollback: the host sends the ring snapshot as the first +// MsgTerminalData on connect, so a fresh Read on the Stream yields the replay. +func TestAttachReplaysScrollback(t *testing.T) { + f := startServe(t, 300) + defer f.cancel() + f.ring.Append([]byte("scrollback-line\n")) + + r := runtimeForFixture("sess", f) + s, err := r.Attach(context.Background(), nameHandle("sess"), 0, 0) + if err != nil { + t.Fatalf("Attach: %v", err) + } + defer s.Close() + + out := readUntil(t, s, "scrollback-line", 2*time.Second) + if !bytes.Contains([]byte(out), []byte("scrollback-line")) { + t.Fatalf("scrollback not replayed on Read; got %q", out) + } +} + +// TestAttachWriteReachesPTY: Write on the Stream sends MsgTerminalInput, which +// the host forwards to the fakePTY's input. +func TestAttachWriteReachesPTY(t *testing.T) { + f := startServe(t, 301) + defer f.cancel() + + r := runtimeForFixture("sess", f) + s, err := r.Attach(context.Background(), nameHandle("sess"), 0, 0) + if err != nil { + t.Fatalf("Attach: %v", err) + } + defer s.Close() + + keystrokes := []byte("ls -la\r") + if _, err := s.Write(keystrokes); err != nil { + t.Fatalf("Write: %v", err) + } + buf := make([]byte, len(keystrokes)) + if _, err := io.ReadFull(f.pty.inR, buf); err != nil { + t.Fatalf("read pty input: %v", err) + } + if string(buf) != string(keystrokes) { + t.Fatalf("pty input = %q, want %q", buf, keystrokes) + } +} + +// TestAttachResizeReachesPTY: an initial size on Attach plus a later Resize both +// reach the fakePTY.Resize via MsgResize frames. +func TestAttachResizeReachesPTY(t *testing.T) { + f := startServe(t, 302) + defer f.cancel() + + r := runtimeForFixture("sess", f) + // Attach with a birth size: the implementation sends an initial MsgResize. + s, err := r.Attach(context.Background(), nameHandle("sess"), 40, 132) + if err != nil { + t.Fatalf("Attach: %v", err) + } + defer s.Close() + + if err := s.Resize(50, 160); err != nil { + t.Fatalf("Resize: %v", err) + } + + // Poll for both resizes (birth + explicit) to arrive on the fakePTY. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + f.pty.resizeMu.Lock() + n := len(f.pty.resizes) + var last ResizePayload + if n > 0 { + last = f.pty.resizes[n-1] + } + f.pty.resizeMu.Unlock() + if n >= 2 && last.Cols == 160 && last.Rows == 50 { + return + } + time.Sleep(10 * time.Millisecond) + } + f.pty.resizeMu.Lock() + defer f.pty.resizeMu.Unlock() + t.Fatalf("resizes did not reach pty as expected: %+v", f.pty.resizes) +} + +// TestAttachUnknownSession: Attach to a session with no resolvable addr errors. +func TestAttachUnknownSession(t *testing.T) { + r := New(Options{}) + if _, err := r.Attach(context.Background(), nameHandle("nope"), 0, 0); err == nil { + t.Fatal("expected error attaching to unknown session") + } +} + +func nameHandle(id string) ports.RuntimeHandle { + return ports.RuntimeHandle{ID: id} +} diff --git a/backend/internal/adapters/runtime/conpty/runtime.go b/backend/internal/adapters/runtime/conpty/runtime.go index 818967bd..efedbe8d 100644 --- a/backend/internal/adapters/runtime/conpty/runtime.go +++ b/backend/internal/adapters/runtime/conpty/runtime.go @@ -1,6 +1,6 @@ -// runtime.go - conpty Runtime adapter. Implements ports.Runtime (minus Attach, -// which comes in B5). Drives sessions via the B3 pty-host over loopback TCP, -// using the B1 protocol and the B2 registry for restart recovery. +// runtime.go - conpty Runtime adapter. Implements ports.Runtime and +// ports.Attacher (see attach.go). Drives sessions via the B3 pty-host over +// loopback TCP, using the B1 protocol and the B2 registry for restart recovery. package conpty import ( @@ -14,7 +14,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// Ensure Runtime satisfies the port at compile time. Attach is added in B5. +// Ensure Runtime satisfies the port at compile time (Attach in attach.go). var _ ports.Runtime = (*Runtime)(nil) // validSessionID matches agent-orchestrator's assertValidSessionId. diff --git a/backend/internal/terminal/pty_unix.go b/backend/internal/adapters/runtime/ptyexec/spawn_unix.go similarity index 66% rename from backend/internal/terminal/pty_unix.go rename to backend/internal/adapters/runtime/ptyexec/spawn_unix.go index c613bbd8..e7d6d2c5 100644 --- a/backend/internal/terminal/pty_unix.go +++ b/backend/internal/adapters/runtime/ptyexec/spawn_unix.go @@ -1,6 +1,10 @@ //go:build !windows -package terminal +// Package ptyexec spawns a local PTY around an attach CLI (tmux/zellij) and +// exposes it as a ports.Stream. It is the shared spawn the terminal layer used +// to own directly; extracting it lets each runtime adapter back its Attach with +// the same creack/pty (unix) or go-pty ConPTY (windows) plumbing. +package ptyexec import ( "context" @@ -11,20 +15,21 @@ import ( "syscall" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/creack/pty" ) -// defaultSpawn starts argv on a real PTY via creack/pty, sized rows×cols from -// birth when a size is known: `zellij attach` reads the tty size once at -// startup, and a post-spawn TIOCSWINSZ depends on SIGWINCH delivery that can -// race the client installing its handler — StartWithSize makes the first read -// correct by construction. env, when non-nil, replaces the inherited -// environment (mirrors exec.Cmd.Env semantics). ctx cancellation closes the PTY -// through the same graceful detach path as an explicit client close. Windows uses -// a stub (see pty_windows.go) until a ConPTY path is added. -func defaultSpawn(ctx context.Context, argv, env []string, rows, cols uint16) (ptyProcess, error) { +// Spawn starts argv on a real PTY via creack/pty, sized rows×cols from birth +// when a size is known: an attach client reads the tty size once at startup, and +// a post-spawn TIOCSWINSZ depends on SIGWINCH delivery that can race the client +// installing its handler — StartWithSize makes the first read correct by +// construction. env, when non-nil, replaces the inherited environment (mirrors +// exec.Cmd.Env semantics). ctx cancellation closes the PTY through the same +// graceful detach path as an explicit client close. Windows uses a ConPTY path +// (see spawn_windows.go). +func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) { if len(argv) == 0 { - return nil, errors.New("terminal: empty attach command") + return nil, errors.New("ptyexec: empty attach command") } if err := ctx.Err(); err != nil { return nil, err @@ -65,7 +70,7 @@ func (p *creackPTY) Resize(rows, cols uint16) error { err := pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols}) // Always follow with an explicit SIGWINCH: the kernel only raises one when // the size actually changed, so a re-asserted (identical) grid would never - // reach a zellij client that missed or lost the original signal — the + // reach an attach client that missed or lost the original signal — the // session would stay laid out for a stale size, with no repaint until the // next real change (the frontend re-sends its grid after each resize burst // for exactly this self-heal; see useTerminalSession). The client re-reads @@ -77,16 +82,16 @@ func (p *creackPTY) Resize(rows, cols uint16) error { } // detachGrace is how long Close waits for a SIGTERM'd attach process to exit -// on its own before falling back to SIGKILL. A zellij client that is being +// on its own before falling back to SIGKILL. An attach client that is being // drained detaches in ~50ms; the grace only runs out for a wedged process. const detachGrace = 250 * time.Millisecond // Close stops the attach process and releases the PTY. // -// SIGTERM first, SIGKILL as fallback: a SIGTERM'd `zellij attach` deregisters -// itself from the zellij server before exiting, while a SIGKILL'd one leaves +// SIGTERM first, SIGKILL as fallback: a SIGTERM'd attach client deregisters +// itself from its mux server before exiting, while a SIGKILL'd one leaves // deregistration to the server noticing the dead socket. A dead-but-registered -// client pins the session's size (zellij sizes a session to its smallest +// client pins the session's size (a mux sizes a session to its smallest // client), so the next attach renders for the ghost's grid — the "terminal // doesn't repaint to the new size" desync. The master stays open through the // grace so the run loop's copyOut keeps draining the client's shutdown output diff --git a/backend/internal/terminal/pty_unix_test.go b/backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go similarity index 89% rename from backend/internal/terminal/pty_unix_test.go rename to backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go index 9a8bd9a0..a0afefa9 100644 --- a/backend/internal/terminal/pty_unix_test.go +++ b/backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go @@ -1,6 +1,6 @@ //go:build !windows -package terminal +package ptyexec import ( "context" @@ -14,7 +14,7 @@ import ( // exactly once. Without the sync.Once a second Wait blocks forever, so this test // would hang (caught by the watchdog) rather than fail. func TestCreackPTYCloseIsIdempotent(t *testing.T) { - p, err := defaultSpawn(context.Background(), []string{"/bin/sh", "-c", "sleep 30"}, nil, 0, 0) + p, err := Spawn(context.Background(), []string{"/bin/sh", "-c", "sleep 30"}, nil, 0, 0) if err != nil { t.Fatalf("spawn: %v", err) } @@ -35,12 +35,12 @@ func TestCreackPTYCloseIsIdempotent(t *testing.T) { // TestCreackPTYResizeSignalsOnIdenticalSize guards the resize self-heal: the // kernel only raises SIGWINCH when TIOCSWINSZ actually changes the size, so a -// re-asserted (identical) grid relies on Resize's explicit signal. A zellij +// re-asserted (identical) grid relies on Resize's explicit signal. An attach // client that lost the original update would otherwise keep its server laid // out for a stale size forever — the "terminal doesn't repaint after resizing // the pane" desync. func TestCreackPTYResizeSignalsOnIdenticalSize(t *testing.T) { - p, err := defaultSpawn(context.Background(), + p, err := Spawn(context.Background(), []string{"/bin/sh", "-c", `trap 'echo WINCHED' WINCH; while :; do sleep 0.05; done`}, nil, 0, 0) if err != nil { t.Fatalf("spawn: %v", err) @@ -80,9 +80,9 @@ func TestCreackPTYResizeSignalsOnIdenticalSize(t *testing.T) { // TestCreackPTYSpawnsAtRequestedSize: the child must see the requested grid on // its very first TIOCGWINSZ, with no SIGWINCH involved — sizing after exec // races the client installing its WINCH handler (a missed signal strands the -// zellij session at the previous client's size). +// session at the previous client's size). func TestCreackPTYSpawnsAtRequestedSize(t *testing.T) { - p, err := defaultSpawn(context.Background(), []string{"/bin/sh", "-c", "stty size"}, nil, 40, 140) + p, err := Spawn(context.Background(), []string{"/bin/sh", "-c", "stty size"}, nil, 40, 140) if err != nil { t.Fatalf("spawn: %v", err) } @@ -107,12 +107,12 @@ func TestCreackPTYSpawnsAtRequestedSize(t *testing.T) { } // TestCreackPTYCloseTermsBeforeKill: Close must give the attach process a -// chance to exit on SIGTERM (a zellij client deregisters from its server on +// chance to exit on SIGTERM (an attach client deregisters from its server on // SIGTERM; a straight SIGKILL leaves a ghost client that pins the session's // size), and must still return promptly for a process that ignores SIGTERM. func TestCreackPTYCloseTermsBeforeKill(t *testing.T) { t.Run("cooperative process exits within the grace", func(t *testing.T) { - p, err := defaultSpawn(context.Background(), + p, err := Spawn(context.Background(), []string{"/bin/sh", "-c", `trap 'exit 0' TERM; while :; do sleep 0.05; done`}, nil, 0, 0) if err != nil { t.Fatalf("spawn: %v", err) @@ -126,7 +126,7 @@ func TestCreackPTYCloseTermsBeforeKill(t *testing.T) { }) t.Run("TERM-ignoring process is killed after the grace", func(t *testing.T) { - p, err := defaultSpawn(context.Background(), + p, err := Spawn(context.Background(), []string{"/bin/sh", "-c", `trap '' TERM; while :; do sleep 0.05; done`}, nil, 0, 0) if err != nil { t.Fatalf("spawn: %v", err) diff --git a/backend/internal/terminal/pty_windows.go b/backend/internal/adapters/runtime/ptyexec/spawn_windows.go similarity index 70% rename from backend/internal/terminal/pty_windows.go rename to backend/internal/adapters/runtime/ptyexec/spawn_windows.go index 5a83400f..8f19f55e 100644 --- a/backend/internal/terminal/pty_windows.go +++ b/backend/internal/adapters/runtime/ptyexec/spawn_windows.go @@ -1,6 +1,6 @@ //go:build windows -package terminal +package ptyexec import ( "context" @@ -8,6 +8,7 @@ import ( "sync" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" winpty "github.com/aymanbagabas/go-pty" ) @@ -16,16 +17,16 @@ import ( // child's stdin) before falling back to Kill. const detachGrace = 250 * time.Millisecond -// defaultSpawn starts argv on a Windows ConPTY and exposes the console pipes -// through the same ptyProcess interface used by the Unix creack/pty path. -// go-pty creates the pseudo-console at 80x25 internally, so we only Resize -// when the caller actually has a grid (mirroring StartWithSize on Unix). -// env, when non-nil, replaces the inherited environment via Win32's native -// CreateProcess env block (mirrors exec.Cmd.Env semantics) — this is how a -// per-session ZELLIJ_SOCKET_DIR reaches the zellij attach client. -func defaultSpawn(ctx context.Context, argv []string, env []string, rows, cols uint16) (ptyProcess, error) { +// Spawn starts argv on a Windows ConPTY and exposes the console pipes through +// the same ports.Stream interface used by the Unix creack/pty path. go-pty +// creates the pseudo-console at 80x25 internally, so we only Resize when the +// caller actually has a grid (mirroring StartWithSize on Unix). env, when +// non-nil, replaces the inherited environment via Win32's native CreateProcess +// env block (mirrors exec.Cmd.Env semantics) — this is how a per-session +// ZELLIJ_SOCKET_DIR reaches the zellij attach client. +func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) { if len(argv) == 0 { - return nil, errors.New("terminal: empty attach command") + return nil, errors.New("ptyexec: empty attach command") } pty, err := winpty.New() if err != nil { diff --git a/backend/internal/terminal/pty_windows_test.go b/backend/internal/adapters/runtime/ptyexec/spawn_windows_test.go similarity index 73% rename from backend/internal/terminal/pty_windows_test.go rename to backend/internal/adapters/runtime/ptyexec/spawn_windows_test.go index ee8c7d61..7dab0ad3 100644 --- a/backend/internal/terminal/pty_windows_test.go +++ b/backend/internal/adapters/runtime/ptyexec/spawn_windows_test.go @@ -1,6 +1,6 @@ //go:build windows -package terminal +package ptyexec import ( "bytes" @@ -10,12 +10,14 @@ import ( "strings" "testing" "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -func TestDefaultSpawnWindowsStreamsOutput(t *testing.T) { - p, err := defaultSpawn(context.Background(), []string{"cmd.exe", "/D", "/Q", "/K"}, nil, 24, 80) +func TestSpawnWindowsStreamsOutput(t *testing.T) { + p, err := Spawn(context.Background(), []string{"cmd.exe", "/D", "/Q", "/K"}, nil, 24, 80) if err != nil { - t.Fatalf("defaultSpawn: %v", err) + t.Fatalf("Spawn: %v", err) } defer p.Close() if _, err := p.Write([]byte("echo AO_CONPTY_OK\r\n")); err != nil { @@ -28,17 +30,17 @@ func TestDefaultSpawnWindowsStreamsOutput(t *testing.T) { } } -func TestDefaultSpawnWindowsRejectsEmptyCommand(t *testing.T) { - _, err := defaultSpawn(context.Background(), nil, nil, 0, 0) +func TestSpawnWindowsRejectsEmptyCommand(t *testing.T) { + _, err := Spawn(context.Background(), nil, nil, 0, 0) if err == nil || !strings.Contains(err.Error(), "empty attach command") { t.Fatalf("expected empty attach command error, got %v", err) } } func TestConPTYCloseIsIdempotent(t *testing.T) { - p, err := defaultSpawn(context.Background(), []string{"cmd.exe", "/D", "/Q", "/K"}, nil, 24, 80) + p, err := Spawn(context.Background(), []string{"cmd.exe", "/D", "/Q", "/K"}, nil, 24, 80) if err != nil { - t.Fatalf("defaultSpawn: %v", err) + t.Fatalf("Spawn: %v", err) } done := make(chan struct{}) @@ -55,7 +57,7 @@ func TestConPTYCloseIsIdempotent(t *testing.T) { } } -func readPTYUntil(t *testing.T, p ptyProcess, marker string, timeout time.Duration) string { +func readPTYUntil(t *testing.T, p ports.Stream, marker string, timeout time.Duration) string { t.Helper() type result struct { out string diff --git a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go index e6ca4a04..8e486db3 100644 --- a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go +++ b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go @@ -15,12 +15,13 @@ import ( // Runtime is the union interface that both tmux and zellij satisfy. // It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods -// the daemon wires directly. +// the daemon wires directly, including ports.Attacher (Attach) so the terminal +// layer can open a Stream against the selected runtime. type Runtime interface { ports.Runtime // Create, Destroy, IsAlive + ports.Attacher SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) - AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) } // Compile-time assertions: both adapters must implement the union interface. diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go index 90235ca1..4cd00e94 100644 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -15,6 +15,7 @@ import ( "time" "unicode/utf8" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/ptyexec" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -48,6 +49,7 @@ type Runtime struct { } var _ ports.Runtime = (*Runtime)(nil) +var _ ports.Attacher = (*Runtime)(nil) type runner interface { Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) @@ -219,10 +221,21 @@ func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lin return tailLines(trimTrailingBlankLines(string(out)), lines), nil } -// AttachCommand returns the argv a human runs to attach their terminal to the +// Attach opens a fresh attach Stream by spawning `tmux attach-session` on a +// local PTY, sized rows x cols from birth when known. ctx cancellation closes +// the PTY. +func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { + argv, env, err := r.attachCommand(handle) + if err != nil { + return nil, err + } + return ptyexec.Spawn(ctx, argv, env, rows, cols) +} + +// attachCommand returns the argv a human runs to attach their terminal to the // session. No per-session env block is needed for tmux (unlike zellij's Windows // ConPTY path), so env is always nil. -func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { +func (r *Runtime) attachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { id, err := handleID(handle) if err != nil { return nil, nil, err diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go index 07003d18..a5979b75 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -527,7 +527,7 @@ func TestGetOutputArgs(t *testing.T) { func TestAttachCommandReturnsExpectedArgv(t *testing.T) { r := New(Options{Binary: "/usr/bin/tmux", Timeout: time.Second}) - argv, env, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1"}) + argv, env, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1"}) if err != nil { t.Fatalf("AttachCommand: %v", err) } @@ -542,7 +542,7 @@ func TestAttachCommandReturnsExpectedArgv(t *testing.T) { func TestAttachCommandRejectsInvalidHandle(t *testing.T) { r := New(Options{}) - _, _, err := r.AttachCommand(ports.RuntimeHandle{ID: ""}) + _, _, err := r.attachCommand(ports.RuntimeHandle{ID: ""}) if err == nil { t.Fatal("AttachCommand empty handle: got nil, want error") } diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go index 03a1f939..d7a84516 100644 --- a/backend/internal/adapters/runtime/zellij/zellij.go +++ b/backend/internal/adapters/runtime/zellij/zellij.go @@ -18,6 +18,7 @@ import ( "time" "unicode/utf8" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/ptyexec" "github.com/aoagents/agent-orchestrator/backend/internal/agentlaunch" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -69,6 +70,7 @@ type Runtime struct { } var _ ports.Runtime = (*Runtime)(nil) +var _ ports.Attacher = (*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 @@ -371,10 +373,22 @@ func deleteSessionMissingOutput(out string) bool { strings.Contains(s, "not a session")) } -// AttachCommand returns the argv a human runs to attach their terminal to the +// Attach opens a fresh attach Stream by spawning the zellij attach client on a +// local PTY, sized rows x cols from birth when known. On Windows the per-session +// env block (ZELLIJ_SOCKET_DIR) is applied to the child. ctx cancellation closes +// the PTY. +func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { + argv, env, err := r.attachCommand(handle) + if err != nil { + return nil, err + } + return ptyexec.Spawn(ctx, argv, env, rows, cols) +} + +// 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) { +func (r *Runtime) attachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { id, _, err := handleID(handle) if err != nil { return nil, nil, err diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go index 657b575f..1a6d6a87 100644 --- a/backend/internal/adapters/runtime/zellij/zellij_test.go +++ b/backend/internal/adapters/runtime/zellij/zellij_test.go @@ -435,7 +435,7 @@ func TestCreateClearsStaleSessionBeforeCreating(t *testing.T) { func TestAttachCommandUsesEmbeddedClientOptions(t *testing.T) { r := New(Options{}) - args, _, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) + args, _, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) if err != nil { t.Fatalf("AttachCommand: %v", err) } @@ -466,7 +466,7 @@ func TestAttachCommandUsesEmbeddedClientOptions(t *testing.T) { func TestAttachCommandUsesSocketDir(t *testing.T) { r := New(Options{SocketDir: "/tmp/zj"}) - args, _, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) + args, _, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) if err != nil { t.Fatalf("AttachCommand: %v", err) } diff --git a/backend/internal/httpd/terminal_mux_test.go b/backend/internal/httpd/terminal_mux_test.go index e0c9929a..7114a06e 100644 --- a/backend/internal/httpd/terminal_mux_test.go +++ b/backend/internal/httpd/terminal_mux_test.go @@ -13,24 +13,25 @@ import ( "github.com/coder/websocket" "github.com/coder/websocket/wsjson" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/ptyexec" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) -// stubSource attaches a throwaway shell command instead of a real Zellij pane, so +// stubSource attaches a throwaway shell command instead of a real mux pane, so // the /mux path exercises the genuine upgrade + wsjson + Serve + creack/pty flow -// without needing Zellij. The pane reports alive until the first attach happens -// (the mux refuses to attach to a dead pane), then dead, so the command's exit is -// treated as the pane being gone (no re-attach). +// without needing a runtime. The pane reports alive until the first attach +// happens (the mux refuses to attach to a dead pane), then dead, so the +// command's exit is treated as the pane being gone (no re-attach). type stubSource struct { argv []string attached atomic.Bool } -func (s *stubSource) AttachCommand(ports.RuntimeHandle) ([]string, []string, error) { +func (s *stubSource) Attach(ctx context.Context, _ ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { s.attached.Store(true) - return s.argv, nil, nil + return ptyexec.Spawn(ctx, s.argv, nil, rows, cols) } func (s *stubSource) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 668da516..97683dc5 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) @@ -99,6 +100,20 @@ type RuntimeHandle struct { ID string } +// Stream is one live terminal attach: PTY-like bytes plus resize. Returned +// already-open by a Runtime's Attach. tmux/zellij back it with a local PTY around +// their attach CLI; conpty backs it with a loopback connection to the pty-host. +type Stream interface { + io.ReadWriteCloser + Resize(rows, cols uint16) error +} + +// Attacher opens a fresh attach Stream for a session handle, sized rows x cols from +// birth (0 means size not yet known). ctx cancellation must terminate the stream. +type Attacher interface { + Attach(ctx context.Context, handle RuntimeHandle, rows, cols uint16) (Stream, error) +} + // The Agent port and its supporting types live in agent.go. // Workspace is the isolated checkout an agent works in (a git worktree or clone). diff --git a/backend/internal/terminal/attachment.go b/backend/internal/terminal/attachment.go index e45bca6c..35268989 100644 --- a/backend/internal/terminal/attachment.go +++ b/backend/internal/terminal/attachment.go @@ -3,7 +3,6 @@ package terminal import ( "context" "errors" - "io" "log/slog" "sync" "time" @@ -11,38 +10,17 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// PTYSource is what a terminal needs from the runtime: the argv that attaches a -// PTY to a session's pane (plus any env that argv needs but is not in the -// daemon's process env — e.g. a per-session ZELLIJ_SOCKET_DIR on Windows), -// and a liveness check used to decide whether a dropped PTY should be -// re-attached or treated as a clean exit. The Zellij runtime adapter -// satisfies this via AttachCommand/IsAlive; the interface lives here, next to -// its only consumer, so terminal does not depend on a concrete adapter. -type PTYSource interface { - AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) +// Source is what the terminal needs from the runtime: open an attach Stream and +// a liveness check used to decide whether a dropped Stream should be re-attached +// or treated as a clean exit. The runtime adapters (tmux/zellij/conpty) satisfy +// it via Attach/IsAlive; the interface lives here, next to its only consumer, so +// terminal does not depend on a concrete adapter. +type Source interface { + ports.Attacher IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) } -// ptyProcess is a started PTY-backed attach process. It is the injection seam -// that keeps the attach loop testable without a real process: unit tests supply -// a scripted in-memory implementation; production uses a creack/pty-backed one -// (see pty_unix.go). -type ptyProcess interface { - io.ReadWriteCloser - Resize(rows, cols uint16) error -} - -// spawnFunc starts a PTY for argv, sized rows×cols from the start (zero means -// no size was recorded yet — kernel default). Spawning at the client's grid -// matters: the attach process reads the tty size once at startup, and sizing -// the PTY only after exec relies on SIGWINCH delivery that can race the -// process installing its handler — a missed signal leaves the zellij client -// laid out for a stale size. env, when non-nil, is the full env block for the -// child (mirrors exec.Cmd.Env: nil means inherit). ctx cancellation must -// terminate the process. -type spawnFunc func(ctx context.Context, argv []string, env []string, rows, cols uint16) (ptyProcess, error) - -// reattach policy: a PTY that drops is re-attached while the underlying Zellij +// reattach policy: a Stream that drops is re-attached while the underlying // session is still alive, up to maxReattach consecutive failures. An attach that // survived longer than reattachResetGrace before dropping resets the counter, so // a long-lived pane that blips recovers but a tight crash-loop gives up. @@ -51,25 +29,24 @@ const ( defaultReattachResetTime = 5 * time.Second ) -// attachment is ONE client's hold on a pane: a private `zellij attach` PTY -// spawned per mux open, streaming to a single sink. Zellij is the multiplexer — -// it owns the session's screen state and scrollback, and answers every fresh -// attach with its init handshake (alt screen, bracketed paste, and other terminal -// modes enabled by the embedded client options) followed by a faithful repaint. -// That handshake is why the PTY is per-client and there is no server-side replay -// buffer: a byte ring can replay recent output, but the one-time mode negotiation -// at the head of the stream scrolls out of any bounded buffer. A fresh attach per -// client makes Zellij re-send it, every time, by construction. +// attachment is ONE client's hold on a pane: a private attach Stream opened per +// mux open, streaming to a single sink. The runtime is the multiplexer — it owns +// the session's screen state and scrollback, and answers every fresh attach with +// its init handshake (alt screen, bracketed paste, scrollback replay) followed by +// a faithful repaint. That handshake is why the Stream is per-client and there is +// no terminal-layer replay buffer: a byte ring can replay recent output, but the +// one-time mode negotiation at the head of the stream scrolls out of any bounded +// buffer. A fresh attach per client makes the runtime re-send it, every time, by +// construction. // -// onOpen fires once the attach PTY is actually ready to accept input. onData +// onOpen fires once the attach Stream is actually ready to accept input. onData // must not block: the WS layer funnels frames onto its own buffered writer. // onExit fires at most once, when the attach loop gives up (runtime dead, // attach failure cap) — never on close(). type attachment struct { id string handle ports.RuntimeHandle - src PTYSource - spawn spawnFunc + src Source log *slog.Logger onOpen func() onData func(data []byte) @@ -79,7 +56,7 @@ type attachment struct { resetGrace time.Duration mu sync.Mutex - pty ptyProcess + pty ports.Stream cancel context.CancelFunc rows uint16 // last size the client asked for; re-applied on every attach cols uint16 @@ -90,7 +67,7 @@ type attachment struct { pendingInput [][]byte } -func newAttachment(id string, handle ports.RuntimeHandle, src PTYSource, spawn spawnFunc, onOpen func(), onData func([]byte), onExit func(), log *slog.Logger) *attachment { +func newAttachment(id string, handle ports.RuntimeHandle, src Source, onOpen func(), onData func([]byte), onExit func(), log *slog.Logger) *attachment { if log == nil { log = slog.Default() } @@ -101,7 +78,6 @@ func newAttachment(id string, handle ports.RuntimeHandle, src PTYSource, spawn s id: id, handle: handle, src: src, - spawn: spawn, log: log, onOpen: onOpen, onData: onData, @@ -128,7 +104,7 @@ func (a *attachment) run(ctx context.Context) { } // Gate EVERY attach (including the first) on the runtime actually - // being alive. `zellij attach` resurrects EXITED sessions — re-running + // being alive. A mux attach resurrects EXITED sessions — re-running // the serialized agent command — so attaching to a dead handle would // re-create a runtime the daemon already destroyed, outside lifecycle // control. A definitive "not alive" is a clean exit. A probe ERROR is @@ -154,19 +130,11 @@ func (a *attachment) run(ctx context.Context) { return } - argv, env, err := a.src.AttachCommand(a.handle) - if a.shouldStop(ctx) { - return - } - if err != nil { - a.fail("attach command: " + err.Error()) - return - } rows, cols := a.size() if a.shouldStop(ctx) { return } - p, err := a.spawn(ctx, argv, env, rows, cols) + p, err := a.src.Attach(ctx, a.handle, rows, cols) if a.shouldStop(ctx) { if p != nil { _ = p.Close() @@ -176,7 +144,7 @@ func (a *attachment) run(ctx context.Context) { if err != nil { failures++ if failures > a.maxReattach { - a.fail("spawn pty: " + err.Error()) + a.fail("attach: " + err.Error()) return } if !a.backoff(ctx, failures) { @@ -214,7 +182,7 @@ func (a *attachment) run(ctx context.Context) { } // copyOut pumps PTY output to the sink until the PTY closes or errors. -func (a *attachment) copyOut(p ptyProcess) { +func (a *attachment) copyOut(p ports.Stream) { buf := make([]byte, 32*1024) for { n, err := p.Read(buf) @@ -291,19 +259,19 @@ func (a *attachment) resize(rows, cols uint16) error { } // size returns the client's last requested grid (zero before the first -// open/resize recorded one). The spawn path reads it so the PTY starts at the -// client's grid instead of the kernel default. +// open/resize recorded one). The attach path reads it so the Stream starts at +// the client's grid instead of the kernel default. func (a *attachment) size() (rows, cols uint16) { a.mu.Lock() defer a.mu.Unlock() return a.rows, a.cols } -// setPTY publishes a freshly attached PTY and replays the client's last -// requested size onto it (see resize) — the spawn already started at the size +// setPTY publishes a freshly attached Stream and replays the client's last +// requested size onto it (see resize) — the attach already started at the size // read in run, but a resize frame can land between that read and registration -// here; the replay (Setsize + explicit WINCH) converges the late case. -func (a *attachment) setPTY(p ptyProcess) bool { +// here; the replay (Resize) converges the late case. +func (a *attachment) setPTY(p ports.Stream) bool { a.mu.Lock() if a.closed || a.exited { a.mu.Unlock() @@ -345,7 +313,7 @@ func (a *attachment) setPTY(p ptyProcess) bool { } } -func (a *attachment) clearPTY(p ptyProcess) { +func (a *attachment) clearPTY(p ports.Stream) { a.mu.Lock() if a.pty == p { a.pty = nil @@ -355,7 +323,7 @@ func (a *attachment) clearPTY(p ptyProcess) { } // close detaches this client: stop re-attaching and kill the attach PTY. It -// never touches the Zellij session itself, which the zellij server keeps alive +// never touches the runtime session itself, which the mux server keeps alive // for other clients. func (a *attachment) close() { a.mu.Lock() diff --git a/backend/internal/terminal/attachment_integration_test.go b/backend/internal/terminal/attachment_integration_test.go index c2eb899d..452362f6 100644 --- a/backend/internal/terminal/attachment_integration_test.go +++ b/backend/internal/terminal/attachment_integration_test.go @@ -43,7 +43,7 @@ func TestAttachmentStreamsRealZellijPane(t *testing.T) { t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) }) var got safeBytes - a := newAttachment(name, handle, rt, defaultSpawn, nil, got.add, nil, testLogger()) + a := newAttachment(name, handle, rt, nil, got.add, nil, testLogger()) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go a.run(ctx) @@ -98,7 +98,7 @@ func TestAttachmentReattachAdoptsNewSize(t *testing.T) { attachAt := func(rows, cols uint16) (*attachment, *safeBytes, <-chan struct{}, context.CancelFunc) { var got safeBytes opened := make(chan struct{}) - a := newAttachment(name, handle, rt, defaultSpawn, func() { close(opened) }, got.add, nil, testLogger()) + a := newAttachment(name, handle, rt, func() { close(opened) }, got.add, nil, testLogger()) if err := a.resize(rows, cols); err != nil { t.Fatalf("record size: %v", err) } diff --git a/backend/internal/terminal/attachment_test.go b/backend/internal/terminal/attachment_test.go index a0851948..634edfc4 100644 --- a/backend/internal/terminal/attachment_test.go +++ b/backend/internal/terminal/attachment_test.go @@ -13,27 +13,27 @@ import ( func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } -func newTestAttachment(src PTYSource, spawn spawnFunc, onData func([]byte), onExit func()) *attachment { - return newTestAttachmentWithOpen(src, spawn, nil, onData, onExit) +func newTestAttachment(src Source, onData func([]byte), onExit func()) *attachment { + return newTestAttachmentWithOpen(src, nil, onData, onExit) } -func newTestAttachmentWithOpen(src PTYSource, spawn spawnFunc, onOpen func(), onData func([]byte), onExit func()) *attachment { - return newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, src, spawn, onOpen, onData, onExit, testLogger()) +func newTestAttachmentWithOpen(src Source, onOpen func(), onData func([]byte), onExit func()) *attachment { + return newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, src, onOpen, onData, onExit, testLogger()) } -func currentPTY(a *attachment) ptyProcess { +func currentPTY(a *attachment) ports.Stream { a.mu.Lock() defer a.mu.Unlock() return a.pty } func TestAttachmentStreamsOutputToSink(t *testing.T) { - src := &fakeSource{alive: true} pty := newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{pty}} + src := &fakeSource{alive: true, spawner: sp} var sink safeBytes - a := newTestAttachment(src, sp.spawn, sink.add, nil) + a := newTestAttachment(src, sink.add, nil) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -44,10 +44,10 @@ func TestAttachmentStreamsOutputToSink(t *testing.T) { } func TestAttachmentWriteAndResizeReachPTY(t *testing.T) { - src := &fakeSource{alive: true} pty := newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{pty}} - a := newTestAttachment(src, sp.spawn, nil, nil) + src := &fakeSource{alive: true, spawner: sp} + a := newTestAttachment(src, nil, nil) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -66,13 +66,13 @@ func TestAttachmentWriteAndResizeReachPTY(t *testing.T) { } func TestAttachmentSignalsOpenOnlyAfterPTYIsPublished(t *testing.T) { - src := &fakeSource{alive: true} pty := newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{pty}} + src := &fakeSource{alive: true, spawner: sp} opened := make(chan bool, 1) var a *attachment - a = newTestAttachmentWithOpen(src, sp.spawn, func() { + a = newTestAttachmentWithOpen(src, func() { opened <- currentPTY(a) == pty }, nil, nil) @@ -91,16 +91,15 @@ func TestAttachmentSignalsOpenOnlyAfterPTYIsPublished(t *testing.T) { } func TestAttachmentBuffersInputUntilPTYReady(t *testing.T) { - src := &fakeSource{alive: true} pty := newFakePTY() spawnStarted := make(chan struct{}) releaseSpawn := make(chan struct{}) - spawn := func(context.Context, []string, []string, uint16, uint16) (ptyProcess, error) { + src := &fakeSource{alive: true, attachFn: func(context.Context, uint16, uint16) (ports.Stream, error) { close(spawnStarted) <-releaseSpawn return pty, nil - } - a := newTestAttachment(src, spawn, nil, nil) + }} + a := newTestAttachment(src, nil, nil) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -123,10 +122,10 @@ func TestAttachmentBuffersInputUntilPTYReady(t *testing.T) { // resize racing the attach) must not be lost: the attach applies it the moment // the PTY is up, instead of leaving the pane at the kernel default grid. func TestAttachmentAppliesRequestedSizeOnAttach(t *testing.T) { - src := &fakeSource{alive: true} pty := newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{pty}} - a := newTestAttachment(src, sp.spawn, nil, nil) + src := &fakeSource{alive: true, spawner: sp} + a := newTestAttachment(src, nil, nil) if err := a.resize(30, 100); err != nil { t.Fatalf("resize before attach: %v", err) @@ -142,18 +141,18 @@ func TestAttachmentAppliesRequestedSizeOnAttach(t *testing.T) { }) } -// The PTY must be SPAWNED at the recorded grid, not sized after exec: the -// attach process (zellij) reads the tty size once at startup, and a post-spawn -// TIOCSWINSZ depends on SIGWINCH delivery that can race the client installing -// its handler — a missed signal left the session laid out for the previous -// client's size (the "terminal doesn't repaint after a resize" desync). Also -// covers re-attach: a later resize must reach the NEXT spawn, not the first -// grid forever. +// The Stream must be OPENED at the recorded grid, not sized after attach: the +// attach client reads the tty size once at startup, and a post-attach resize +// depends on SIGWINCH delivery that can race the client installing its handler +// — a missed signal left the session laid out for the previous client's size +// (the "terminal doesn't repaint after a resize" desync). Also covers +// re-attach: a later resize must reach the NEXT attach, not the first grid +// forever. func TestAttachmentSpawnsPTYAtRecordedSize(t *testing.T) { - src := &fakeSource{alive: true} first := newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{first}} - a := newTestAttachment(src, sp.spawn, nil, nil) + src := &fakeSource{alive: true, spawner: sp} + a := newTestAttachment(src, nil, nil) a.resetGrace = time.Hour // keep the failure counter deterministic if err := a.resize(37, 115); err != nil { @@ -183,19 +182,19 @@ func TestAttachmentSpawnsPTYAtRecordedSize(t *testing.T) { } func TestAttachmentSkipsReattachOnCleanExit(t *testing.T) { - src := &fakeSource{alive: true} // alive for the first attach pty := newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{pty}} + src := &fakeSource{alive: true, spawner: sp} // alive for the first attach exited := make(chan struct{}) - a := newTestAttachment(src, sp.spawn, nil, func() { close(exited) }) + a := newTestAttachment(src, nil, func() { close(exited) }) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go a.run(ctx) eventually(t, time.Second, func() bool { return sp.calls() == 1 }) - src.setAlive(false) // Zellij session gone -> no re-attach + src.setAlive(false) // runtime session gone -> no re-attach pty.Close() // pane ends select { case <-exited: @@ -207,17 +206,17 @@ func TestAttachmentSkipsReattachOnCleanExit(t *testing.T) { } } -// TestAttachmentNeverAttachesToDeadRuntime covers the resurrection bug: `zellij -// attach` on a killed-but-cached session resurrects it, re-running the agent +// TestAttachmentNeverAttachesToDeadRuntime covers the resurrection bug: a mux +// attach on a killed-but-cached session resurrects it, re-running the agent // command. An attachment whose runtime probes definitively dead must therefore -// report exited WITHOUT ever spawning an attach PTY — even on the very first +// report exited WITHOUT ever opening an attach Stream — even on the very first // open (the original code only checked liveness on re-attach). func TestAttachmentNeverAttachesToDeadRuntime(t *testing.T) { - src := &fakeSource{alive: false} sp := &fakeSpawner{} + src := &fakeSource{alive: false, spawner: sp} exited := make(chan struct{}) - a := newTestAttachment(src, sp.spawn, nil, func() { close(exited) }) + a := newTestAttachment(src, nil, func() { close(exited) }) go a.run(context.Background()) select { @@ -226,7 +225,7 @@ func TestAttachmentNeverAttachesToDeadRuntime(t *testing.T) { t.Fatal("expected exit when runtime is dead before first attach") } if got := sp.calls(); got != 0 { - t.Fatalf("attach must never run against a dead runtime, got %d spawns", got) + t.Fatalf("attach must never run against a dead runtime, got %d attaches", got) } } @@ -235,10 +234,10 @@ func TestAttachmentNeverAttachesToDeadRuntime(t *testing.T) { // not flip the terminal to exited, and the attach proceeds once the probe // recovers. func TestAttachmentRetriesProbeErrorsBeforeAttaching(t *testing.T) { - src := &fakeSource{aliveErr: io.ErrUnexpectedEOF} pty := newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{pty}} - a := newTestAttachment(src, sp.spawn, nil, nil) + src := &fakeSource{aliveErr: io.ErrUnexpectedEOF, spawner: sp} + a := newTestAttachment(src, nil, nil) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -250,7 +249,7 @@ func TestAttachmentRetriesProbeErrorsBeforeAttaching(t *testing.T) { t.Fatal("probe error must not be treated as runtime death") } if got := sp.calls(); got != 0 { - t.Fatalf("attach must wait for a successful probe, got %d spawns", got) + t.Fatalf("attach must wait for a successful probe, got %d attaches", got) } // Probe recovers -> the attach goes through. @@ -262,10 +261,10 @@ func TestAttachmentRetriesProbeErrorsBeforeAttaching(t *testing.T) { } func TestAttachmentReattachesWhileSessionAlive(t *testing.T) { - src := &fakeSource{alive: true} // session still alive -> re-attach on drop p1, p2 := newFakePTY(), newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{p1, p2}} - a := newTestAttachment(src, sp.spawn, nil, nil) + src := &fakeSource{alive: true, spawner: sp} // session still alive -> re-attach on drop + a := newTestAttachment(src, nil, nil) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -295,34 +294,36 @@ func TestAttachmentReattachesWhileSessionAlive(t *testing.T) { eventually(t, 2*time.Second, func() bool { return a.isExited() }) } -func TestAttachmentFailsWhenAttachCommandErrors(t *testing.T) { +// A persistent Attach error is treated like the old spawn-error path: it backs +// off and retries up to the failure cap, then reports exited. (The old +// AttachCommand-error path failed immediately; folding the argv build into +// Attach means an attach failure now shares the spawn-failure retry policy, +// which is the correct behavior for a transient dial/exec failure.) +func TestAttachmentFailsWhenAttachErrors(t *testing.T) { src := &fakeSource{alive: true, attachErr: io.ErrUnexpectedEOF} - sp := &fakeSpawner{} exited := make(chan struct{}) - a := newTestAttachment(src, sp.spawn, nil, func() { close(exited) }) + a := newTestAttachment(src, nil, func() { close(exited) }) + a.maxReattach = 2 // keep the retry budget small so the test is fast go a.run(context.Background()) select { case <-exited: - case <-time.After(time.Second): - t.Fatal("expected exit when attach command fails") - } - if sp.calls() != 0 { - t.Fatalf("spawn should not run when attach command errors, got %d calls", sp.calls()) + case <-time.After(3 * time.Second): + t.Fatal("expected exit after attach errors exhaust the retry budget") } } // close() is a detach, not a pane death: it must stop the attach loop and kill -// the client's PTY without firing onExit — the Zellij session is still alive +// the client's PTY without firing onExit — the runtime session is still alive // and an exited frame would wrongly flip the client UI to its terminal state. func TestAttachmentCloseDoesNotFireExit(t *testing.T) { - src := &fakeSource{alive: true} pty := newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{pty}} + src := &fakeSource{alive: true, spawner: sp} exited := make(chan struct{}) - a := newTestAttachment(src, sp.spawn, nil, func() { close(exited) }) + a := newTestAttachment(src, nil, func() { close(exited) }) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -346,7 +347,7 @@ func TestAttachmentCloseDoesNotFireExit(t *testing.T) { default: } if got := sp.calls(); got != 1 { - t.Fatalf("close must stop re-attaching, got %d spawns", got) + t.Fatalf("close must stop re-attaching, got %d attaches", got) } } @@ -371,11 +372,10 @@ func (p *closeOrderPTY) Close() error { } func TestAttachmentCloseClosesPTYBeforeCancel(t *testing.T) { - src := &fakeSource{alive: true} beforeCancel := make(chan struct{}) afterCancel := make(chan struct{}) var spawnCtx context.Context - spawn := func(ctx context.Context, _ []string, _ []string, _, _ uint16) (ptyProcess, error) { + src := &fakeSource{alive: true, attachFn: func(ctx context.Context, _, _ uint16) (ports.Stream, error) { spawnCtx = ctx return &closeOrderPTY{ fakePTY: newFakePTY(), @@ -383,8 +383,8 @@ func TestAttachmentCloseClosesPTYBeforeCancel(t *testing.T) { before: beforeCancel, after: afterCancel, }, nil - } - a := newTestAttachment(src, spawn, nil, nil) + }} + a := newTestAttachment(src, nil, nil) done := make(chan struct{}) go func() { diff --git a/backend/internal/terminal/fakes_test.go b/backend/internal/terminal/fakes_test.go index c0818f3d..b196aec4 100644 --- a/backend/internal/terminal/fakes_test.go +++ b/backend/internal/terminal/fakes_test.go @@ -10,23 +10,29 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// fakeSource is a scripted PTYSource. +// fakeSource is a scripted terminal Source: Attach hands out fake Streams from +// an embedded spawner (or a custom attachFn closure); IsAlive is scriptable. +// attachErr makes Attach fail. type fakeSource struct { - argv []string + spawner *fakeSpawner + attachFn func(ctx context.Context, rows, cols uint16) (ports.Stream, error) mu sync.Mutex alive bool aliveErr error attachErr error } -func (f *fakeSource) AttachCommand(ports.RuntimeHandle) ([]string, []string, error) { +func (f *fakeSource) Attach(ctx context.Context, _ ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { if f.attachErr != nil { - return nil, nil, f.attachErr + return nil, f.attachErr } - if f.argv == nil { - return []string{"zellij", "attach"}, nil, nil + if f.attachFn != nil { + return f.attachFn(ctx, rows, cols) } - return f.argv, nil, nil + if f.spawner == nil { + f.spawner = &fakeSpawner{} + } + return f.spawner.spawn(rows, cols) } func (f *fakeSource) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { @@ -48,8 +54,8 @@ func (f *fakeSource) setAliveResult(v bool, err error) { f.mu.Unlock() } -// fakePTY is a scripted ptyProcess: Read drains the out channel, Write records, -// Resize records, and Close unblocks reads. +// fakePTY is a scripted ports.Stream: Read drains the out channel, Write +// records, Resize records, and Close unblocks reads. type fakePTY struct { out chan []byte closed chan struct{} @@ -110,16 +116,17 @@ func (p *fakePTY) resizeCalls() [][2]uint16 { // fakeSpawner hands out pre-built fakePTYs in order; once exhausted it returns // idle PTYs that block until closed (so a re-attach loop does not busy-spin). +// It is the attach seam the fakeSource backs: each Attach call is one spawn. type fakeSpawner struct { mu sync.Mutex ptys []*fakePTY n int err error created []*fakePTY - sizes [][2]uint16 // rows×cols passed to each spawn call, in order + sizes [][2]uint16 // rows×cols passed to each attach call, in order } -func (f *fakeSpawner) spawn(_ context.Context, _ []string, _ []string, rows, cols uint16) (ptyProcess, error) { +func (f *fakeSpawner) spawn(rows, cols uint16) (ports.Stream, error) { f.mu.Lock() defer f.mu.Unlock() if f.err != nil { diff --git a/backend/internal/terminal/logger_test.go b/backend/internal/terminal/logger_test.go index ba08a2db..1586e458 100644 --- a/backend/internal/terminal/logger_test.go +++ b/backend/internal/terminal/logger_test.go @@ -7,12 +7,12 @@ import ( ) func TestNilLoggerFallsBackToDefault(t *testing.T) { - mgr := NewManager(&fakeSource{}, nil, nil, WithSpawn((&fakeSpawner{}).spawn)) + mgr := NewManager(&fakeSource{}, nil, nil) defer mgr.Close() if mgr.log == nil { t.Fatal("manager logger is nil") } - a := newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, &fakeSource{}, (&fakeSpawner{}).spawn, nil, nil, nil, nil) + a := newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, &fakeSource{}, nil, nil, nil, nil) if a.log == nil { t.Fatal("attachment logger is nil") } diff --git a/backend/internal/terminal/manager.go b/backend/internal/terminal/manager.go index 88ff36ed..6dccfaee 100644 --- a/backend/internal/terminal/manager.go +++ b/backend/internal/terminal/manager.go @@ -34,15 +34,14 @@ const ( defaultWriteBuffer = 1024 ) -// Manager serves WebSocket clients, spawning one attach PTY per opened pane +// Manager serves WebSocket clients, opening one attach Stream per opened pane // per connection. There is no shared per-pane state to outlive a connection: -// the Zellij server owns the session (screen, scrollback, modes), and every -// fresh attach gets its full handshake + repaint. A client reconnect simply -// attaches again. +// the runtime owns the session (screen, scrollback, modes), and every fresh +// attach gets its full handshake + repaint. A client reconnect simply attaches +// again. type Manager struct { - src PTYSource + src Source events EventSource - spawn spawnFunc log *slog.Logger heartbeat time.Duration @@ -58,15 +57,12 @@ type Manager struct { // Option configures a Manager. type Option func(*Manager) -// WithSpawn overrides the PTY spawner (tests inject a fake). -func WithSpawn(fn spawnFunc) Option { return func(m *Manager) { m.spawn = fn } } - // WithHeartbeat overrides the ping interval. func WithHeartbeat(d time.Duration) Option { return func(m *Manager) { m.heartbeat = d } } -// NewManager builds a Manager. src attaches PTYs; events feeds the session +// NewManager builds a Manager. src opens attach Streams; events feeds the session // channel (may be nil to disable it). A nil logger falls back to slog.Default. -func NewManager(src PTYSource, events EventSource, log *slog.Logger, opts ...Option) *Manager { +func NewManager(src Source, events EventSource, log *slog.Logger, opts ...Option) *Manager { if log == nil { log = slog.Default() } @@ -74,7 +70,6 @@ func NewManager(src PTYSource, events EventSource, log *slog.Logger, opts ...Opt m := &Manager{ src: src, events: events, - spawn: defaultSpawn, log: log, heartbeat: defaultHeartbeat, ctx: ctx, @@ -205,8 +200,8 @@ func (c *connState) handleTerminal(msg clientMsg) { } } -// openTerminal spawns this connection's own attach PTY for the pane. rows/cols -// are the client's grid from the open frame, applied as the PTY's initial size +// openTerminal opens this connection's own attach Stream for the pane. rows/cols +// are the client's grid from the open frame, applied as the Stream's initial size // (a resize that raced ahead of the attach would otherwise be lost). func (c *connState) openTerminal(id string, rows, cols uint16) { if id == "" { @@ -223,7 +218,7 @@ func (c *connState) openTerminal(id string, rows, cols uint16) { // a is captured by onExit before assignment; safe because the attach loop — // the only thing that fires onExit — starts after the registration below. var a *attachment - a = newAttachment(id, ports.RuntimeHandle{ID: id}, c.mgr.src, c.mgr.spawn, + a = newAttachment(id, ports.RuntimeHandle{ID: id}, c.mgr.src, func() { c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgOpened}) }, diff --git a/backend/internal/terminal/manager_test.go b/backend/internal/terminal/manager_test.go index 8c3e3ee1..3bb2134b 100644 --- a/backend/internal/terminal/manager_test.go +++ b/backend/internal/terminal/manager_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) // fakeConn is an in-memory wsConn driven by channels. @@ -68,10 +69,10 @@ func recv(t *testing.T, c *fakeConn, ch, typ string, d time.Duration) serverMsg } func TestServeOpenStreamsAndWritesTerminal(t *testing.T) { - src := &fakeSource{alive: true} pty := newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{pty}} - mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) + src := &fakeSource{alive: true, spawner: sp} + mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) defer mgr.Close() conn := newFakeConn() @@ -100,16 +101,15 @@ func TestServeOpenStreamsAndWritesTerminal(t *testing.T) { } func TestServeBuffersInputUntilAttachReady(t *testing.T) { - src := &fakeSource{alive: true} pty := newFakePTY() spawnStarted := make(chan struct{}) releaseSpawn := make(chan struct{}) - spawn := func(context.Context, []string, []string, uint16, uint16) (ptyProcess, error) { + src := &fakeSource{alive: true, attachFn: func(context.Context, uint16, uint16) (ports.Stream, error) { close(spawnStarted) <-releaseSpawn return pty, nil - } - mgr := NewManager(src, nil, testLogger(), WithSpawn(spawn), WithHeartbeat(0)) + }} + mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) defer mgr.Close() conn := newFakeConn() @@ -148,9 +148,9 @@ func nextTerminal(t *testing.T, c *fakeConn) serverMsg { // same id on this connection is served instead of being silently dropped by the // already-open guard. func TestServeOpenDeadRuntimeReportsExitedAndAllowsReopen(t *testing.T) { - src := &fakeSource{alive: false} sp := &fakeSpawner{ptys: []*fakePTY{newFakePTY()}} - mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) + src := &fakeSource{alive: false, spawner: sp} + mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) defer mgr.Close() conn := newFakeConn() @@ -163,7 +163,7 @@ func TestServeOpenDeadRuntimeReportsExitedAndAllowsReopen(t *testing.T) { t.Fatalf("first frame = %q, want exited", m.Type) } if got := sp.calls(); got != 0 { - t.Fatalf("attach must never run against a dead runtime, got %d spawns", got) + t.Fatalf("attach must never run against a dead runtime, got %d attaches", got) } src.setAlive(true) @@ -177,10 +177,10 @@ func TestServeOpenDeadRuntimeReportsExitedAndAllowsReopen(t *testing.T) { // exit, so a later open for the same id is served rather than dropped by the // already-open guard. func TestServeExitAfterOpenClearsEntryAllowingReopen(t *testing.T) { - src := &fakeSource{alive: true} // alive for the first attach p := newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{p}} - mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) + src := &fakeSource{alive: true, spawner: sp} // alive for the first attach + mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) defer mgr.Close() conn := newFakeConn() @@ -209,10 +209,10 @@ func TestServeExitAfterOpenClearsEntryAllowingReopen(t *testing.T) { // to shake the exit/reopen interleavings out. func TestServeReopenAfterImmediateExitNeverStuck(t *testing.T) { for i := 0; i < 400; i++ { - src := &fakeSource{} - src.setAlive(false) // dead runtime -> the open exits without attaching sp := &fakeSpawner{} - mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) + src := &fakeSource{spawner: sp} + src.setAlive(false) // dead runtime -> the open exits without attaching + mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) conn := newFakeConn() ctx, cancel := context.WithCancel(context.Background()) @@ -233,7 +233,7 @@ func TestServeReopenAfterImmediateExitNeverStuck(t *testing.T) { } func TestServeRejectsOpenWithoutID(t *testing.T) { - mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) + mgr := NewManager(&fakeSource{}, nil, testLogger(), WithHeartbeat(0)) defer mgr.Close() conn := newFakeConn() ctx, cancel := context.WithCancel(context.Background()) @@ -249,7 +249,7 @@ func TestServeRejectsOpenWithoutID(t *testing.T) { func TestServeForwardsSessionChannelFromCDC(t *testing.T) { bc := cdc.NewBroadcaster() - mgr := NewManager(&fakeSource{}, bc, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) + mgr := NewManager(&fakeSource{}, bc, testLogger(), WithHeartbeat(0)) defer mgr.Close() conn := newFakeConn() @@ -271,7 +271,7 @@ func TestServeForwardsSessionChannelFromCDC(t *testing.T) { } func TestServeSystemPingGetsPong(t *testing.T) { - mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) + mgr := NewManager(&fakeSource{}, nil, testLogger(), WithHeartbeat(0)) defer mgr.Close() conn := newFakeConn() ctx, cancel := context.WithCancel(context.Background()) @@ -283,7 +283,7 @@ func TestServeSystemPingGetsPong(t *testing.T) { } func TestServeHeartbeatPings(t *testing.T) { - mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(10*time.Millisecond)) + mgr := NewManager(&fakeSource{}, nil, testLogger(), WithHeartbeat(10*time.Millisecond)) defer mgr.Close() conn := newFakeConn() ctx, cancel := context.WithCancel(context.Background()) @@ -294,7 +294,7 @@ func TestServeHeartbeatPings(t *testing.T) { } func TestServeClosesConnOnReadEnd(t *testing.T) { - mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) + mgr := NewManager(&fakeSource{}, nil, testLogger(), WithHeartbeat(0)) defer mgr.Close() conn := newFakeConn() ctx, cancel := context.WithCancel(context.Background()) @@ -309,15 +309,15 @@ func TestServeClosesConnOnReadEnd(t *testing.T) { } // Each connection opening the same pane gets its OWN attach PTY — that is the -// per-client model: zellij replays its init handshake + full repaint to every +// per-client model: the runtime replays its init handshake + full repaint to every // fresh attach, so no client depends on bytes another client consumed. Output // pushed to one client's PTY must reach only that client, and closing one // client's terminal must not touch the other's PTY. func TestServePerClientAttachIsolation(t *testing.T) { - src := &fakeSource{alive: true} p1, p2 := newFakePTY(), newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{p1, p2}} - mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) + src := &fakeSource{alive: true, spawner: sp} + mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) defer mgr.Close() connA, connB := newFakeConn(), newFakeConn() @@ -334,7 +334,7 @@ func TestServePerClientAttachIsolation(t *testing.T) { recv(t, connB, chTerminal, msgOpened, time.Second) eventually(t, time.Second, func() bool { return sp.calls() == 2 }) - // Zellij fans output out per attach; here each fake PTY stands in for one + // The runtime fans output out per attach; here each fake PTY stands in for one // attach process, so its bytes must reach exactly its own connection. p1.push([]byte("for-A")) data := recv(t, connA, chTerminal, msgData, time.Second) @@ -368,10 +368,10 @@ func TestServePerClientAttachIsolation(t *testing.T) { // The open frame carries the client's grid; the PTY must start at that size // rather than the kernel default, even though the attach is asynchronous. func TestServeOpenAppliesInitialSize(t *testing.T) { - src := &fakeSource{alive: true} pty := newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{pty}} - mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) + src := &fakeSource{alive: true, spawner: sp} + mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) defer mgr.Close() conn := newFakeConn() @@ -390,10 +390,10 @@ func TestServeOpenAppliesInitialSize(t *testing.T) { // Manager.Close must kill every live attach PTY: a PTY left open keeps its // attach process running and deadlocks daemon shutdown. func TestManagerCloseKillsLiveAttachments(t *testing.T) { - src := &fakeSource{alive: true} pty := newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{pty}} - mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) + src := &fakeSource{alive: true, spawner: sp} + mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) conn := newFakeConn() ctx, cancel := context.WithCancel(context.Background()) From 1511f1a3ca591131e9ff1b181b36ab347fcc3279 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 24 Jun 2026 00:32:17 +0530 Subject: [PATCH 17/60] style(ptyexec): replace em dashes carried from moved pty files Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/task-b5-review-package.txt | 3071 +++++++++++++++++ .../adapters/runtime/ptyexec/spawn_unix.go | 6 +- .../runtime/ptyexec/spawn_unix_test.go | 6 +- .../adapters/runtime/ptyexec/spawn_windows.go | 2 +- 4 files changed, 3078 insertions(+), 7 deletions(-) create mode 100644 .superpowers/sdd/task-b5-review-package.txt diff --git a/.superpowers/sdd/task-b5-review-package.txt b/.superpowers/sdd/task-b5-review-package.txt new file mode 100644 index 00000000..fcf8de67 --- /dev/null +++ b/.superpowers/sdd/task-b5-review-package.txt @@ -0,0 +1,3071 @@ +=== COMMITS === +5bfbc48 feat(terminal): stream-based Attach for tmux/zellij/conpty + +=== STAT === +backend/internal/adapters/runtime/conpty/attach.go | 116 ++++++++++++++++ + .../adapters/runtime/conpty/attach_test.go | 151 +++++++++++++++++++++ + .../internal/adapters/runtime/conpty/runtime.go | 8 +- + .../runtime/ptyexec/spawn_unix.go} | 37 ++--- + .../runtime/ptyexec/spawn_unix_test.go} | 18 +-- + .../runtime/ptyexec/spawn_windows.go} | 21 +-- + .../runtime/ptyexec/spawn_windows_test.go} | 20 +-- + .../runtime/runtimeselect/runtimeselect.go | 5 +- + backend/internal/adapters/runtime/tmux/tmux.go | 17 ++- + .../internal/adapters/runtime/tmux/tmux_test.go | 4 +- + backend/internal/adapters/runtime/zellij/zellij.go | 18 ++- + .../adapters/runtime/zellij/zellij_test.go | 4 +- + backend/internal/httpd/terminal_mux_test.go | 13 +- + backend/internal/ports/outbound.go | 15 ++ + backend/internal/terminal/attachment.go | 98 +++++-------- + .../terminal/attachment_integration_test.go | 4 +- + backend/internal/terminal/attachment_test.go | 112 +++++++-------- + backend/internal/terminal/fakes_test.go | 29 ++-- + backend/internal/terminal/logger_test.go | 4 +- + backend/internal/terminal/manager.go | 25 ++-- + backend/internal/terminal/manager_test.go | 54 ++++---- + 21 files changed, 531 insertions(+), 242 deletions(-) +=== DIFF (backend) === +backend/internal/adapters/runtime/conpty/attach.go | 116 ++++++++++++++++ + .../adapters/runtime/conpty/attach_test.go | 151 +++++++++++++++++++++ + .../internal/adapters/runtime/conpty/runtime.go | 8 +- + .../runtime/ptyexec/spawn_unix.go} | 37 ++--- + .../runtime/ptyexec/spawn_unix_test.go} | 18 +-- + .../runtime/ptyexec/spawn_windows.go} | 21 +-- + .../runtime/ptyexec/spawn_windows_test.go} | 20 +-- + .../runtime/runtimeselect/runtimeselect.go | 5 +- + backend/internal/adapters/runtime/tmux/tmux.go | 17 ++- + .../internal/adapters/runtime/tmux/tmux_test.go | 4 +- + backend/internal/adapters/runtime/zellij/zellij.go | 18 ++- + .../adapters/runtime/zellij/zellij_test.go | 4 +- + backend/internal/httpd/terminal_mux_test.go | 13 +- + backend/internal/ports/outbound.go | 15 ++ + backend/internal/terminal/attachment.go | 98 +++++-------- + .../terminal/attachment_integration_test.go | 4 +- + backend/internal/terminal/attachment_test.go | 112 +++++++-------- + backend/internal/terminal/fakes_test.go | 29 ++-- + backend/internal/terminal/logger_test.go | 4 +- + backend/internal/terminal/manager.go | 25 ++-- + backend/internal/terminal/manager_test.go | 54 ++++---- + 21 files changed, 531 insertions(+), 242 deletions(-) + +diff --git a/backend/internal/adapters/runtime/conpty/attach.go b/backend/internal/adapters/runtime/conpty/attach.go +new file mode 100644 +index 0000000..c2e8363 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/attach.go +@@ -0,0 +1,116 @@ ++// attach.go - conpty Attach: a loopback Stream over the B3 pty-host. Unlike ++// tmux/zellij, conpty does not spawn an attach CLI; it dials the session's ++// loopback host and speaks the B1 framing protocol directly. The host replays ++// the scrollback Snapshot as the first MsgTerminalData on connect, so a fresh ++// Read naturally yields the repaint first. ++package conpty ++ ++import ( ++ "context" ++ "encoding/json" ++ "fmt" ++ "io" ++ "sync" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" ++) ++ ++var _ ports.Attacher = (*Runtime)(nil) ++ ++// Attach opens a fresh attach Stream for the session by dialing its loopback ++// pty-host. rows/cols size the host's PTY from birth when known (a MsgResize is ++// sent right after connect). ctx cancellation closes the Stream. ++func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { ++ sess := r.resolve(handle.ID) ++ if sess == nil { ++ return nil, fmt.Errorf("conpty: session %q not found", handle.ID) ++ } ++ conn, err := dialHost(sess.addr, dialTimeout) ++ if err != nil { ++ return nil, fmt.Errorf("conpty: dial host for %q: %w", handle.ID, err) ++ } ++ ++ pr, pw := io.Pipe() ++ s := &loopbackStream{conn: conn, pr: pr, pw: pw} ++ ++ // Pump host frames: MsgTerminalData payloads go into the pipe that Read ++ // drains. The first such frame is the scrollback snapshot, so the replay ++ // arrives before any live output. ++ go s.pump() ++ ++ // ctx cancellation must terminate the stream (mirrors the unix/windows ++ // spawn paths closing the PTY on ctx.Done). ++ go func() { ++ <-ctx.Done() ++ _ = s.Close() ++ }() ++ ++ if rows > 0 && cols > 0 { ++ if err := s.Resize(rows, cols); err != nil { ++ _ = s.Close() ++ return nil, err ++ } ++ } ++ return s, nil ++} ++ ++// loopbackStream is a ports.Stream backed by a single loopback connection to the ++// pty-host. The pump goroutine reframes host output into an io.Pipe so Read ++// presents a plain byte stream; Write/Resize encode client frames onto the conn. ++type loopbackStream struct { ++ conn io.ReadWriteCloser ++ pr *io.PipeReader ++ pw *io.PipeWriter ++ ++ closeOnce sync.Once ++} ++ ++// pump reads framed host messages and writes MsgTerminalData payloads into the ++// pipe. It closes the pipe when the connection ends so Read returns EOF. ++func (s *loopbackStream) pump() { ++ parser := NewMessageParser(func(msgType byte, payload []byte) { ++ if msgType == MsgTerminalData { ++ // Write blocks until Read drains, preserving back-pressure and order. ++ _, _ = s.pw.Write(payload) ++ } ++ }) ++ buf := make([]byte, 4096) ++ for { ++ n, err := s.conn.Read(buf) ++ if n > 0 { ++ parser.Feed(buf[:n]) ++ } ++ if err != nil { ++ _ = s.pw.CloseWithError(err) ++ return ++ } ++ } ++} ++ ++func (s *loopbackStream) Read(p []byte) (int, error) { return s.pr.Read(p) } ++ ++func (s *loopbackStream) Write(p []byte) (int, error) { ++ if _, err := s.conn.Write(EncodeMessage(MsgTerminalInput, p)); err != nil { ++ return 0, err ++ } ++ return len(p), nil ++} ++ ++func (s *loopbackStream) Resize(rows, cols uint16) error { ++ payload, _ := json.Marshal(ResizePayload{Cols: int(cols), Rows: int(rows)}) ++ _, err := s.conn.Write(EncodeMessage(MsgResize, payload)) ++ return err ++} ++ ++// Close closes the conn and the pipe. Idempotent. Closing the conn unblocks ++// pump's Read, which then closes the pipe-writer too; closing both here makes ++// Close safe to call directly (e.g. on ctx cancel) without waiting for pump. ++func (s *loopbackStream) Close() error { ++ var err error ++ s.closeOnce.Do(func() { ++ err = s.conn.Close() ++ _ = s.pw.Close() ++ _ = s.pr.Close() ++ }) ++ return err ++} +diff --git a/backend/internal/adapters/runtime/conpty/attach_test.go b/backend/internal/adapters/runtime/conpty/attach_test.go +new file mode 100644 +index 0000000..08b7ff8 +--- /dev/null ++++ b/backend/internal/adapters/runtime/conpty/attach_test.go +@@ -0,0 +1,151 @@ ++package conpty ++ ++import ( ++ "bytes" ++ "context" ++ "io" ++ "testing" ++ "time" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" ++) ++ ++// runtimeForFixture wires a conpty Runtime to a running serveFixture by stuffing ++// the fixture's loopback addr into the session map under the given id, so Attach ++// resolves it without a real Windows spawn. ++func runtimeForFixture(id string, f *serveFixture) *Runtime { ++ r := New(Options{}) ++ r.mu.Lock() ++ r.sessions[id] = &hostSession{addr: f.addr, pid: f.pty.PID()} ++ r.mu.Unlock() ++ return r ++} ++ ++func readUntil(t *testing.T, s io.Reader, want string, timeout time.Duration) string { ++ t.Helper() ++ type res struct { ++ out string ++ } ++ done := make(chan res, 1) ++ go func() { ++ var buf []byte ++ tmp := make([]byte, 4096) ++ for { ++ n, err := s.Read(tmp) ++ if n > 0 { ++ buf = append(buf, tmp[:n]...) ++ if bytes.Contains(buf, []byte(want)) { ++ done <- res{string(buf)} ++ return ++ } ++ } ++ if err != nil { ++ done <- res{string(buf)} ++ return ++ } ++ } ++ }() ++ select { ++ case r := <-done: ++ return r.out ++ case <-time.After(timeout): ++ t.Fatalf("timed out reading for %q", want) ++ return "" ++ } ++} ++ ++// TestAttachReplaysScrollback: the host sends the ring snapshot as the first ++// MsgTerminalData on connect, so a fresh Read on the Stream yields the replay. ++func TestAttachReplaysScrollback(t *testing.T) { ++ f := startServe(t, 300) ++ defer f.cancel() ++ f.ring.Append([]byte("scrollback-line\n")) ++ ++ r := runtimeForFixture("sess", f) ++ s, err := r.Attach(context.Background(), nameHandle("sess"), 0, 0) ++ if err != nil { ++ t.Fatalf("Attach: %v", err) ++ } ++ defer s.Close() ++ ++ out := readUntil(t, s, "scrollback-line", 2*time.Second) ++ if !bytes.Contains([]byte(out), []byte("scrollback-line")) { ++ t.Fatalf("scrollback not replayed on Read; got %q", out) ++ } ++} ++ ++// TestAttachWriteReachesPTY: Write on the Stream sends MsgTerminalInput, which ++// the host forwards to the fakePTY's input. ++func TestAttachWriteReachesPTY(t *testing.T) { ++ f := startServe(t, 301) ++ defer f.cancel() ++ ++ r := runtimeForFixture("sess", f) ++ s, err := r.Attach(context.Background(), nameHandle("sess"), 0, 0) ++ if err != nil { ++ t.Fatalf("Attach: %v", err) ++ } ++ defer s.Close() ++ ++ keystrokes := []byte("ls -la\r") ++ if _, err := s.Write(keystrokes); err != nil { ++ t.Fatalf("Write: %v", err) ++ } ++ buf := make([]byte, len(keystrokes)) ++ if _, err := io.ReadFull(f.pty.inR, buf); err != nil { ++ t.Fatalf("read pty input: %v", err) ++ } ++ if string(buf) != string(keystrokes) { ++ t.Fatalf("pty input = %q, want %q", buf, keystrokes) ++ } ++} ++ ++// TestAttachResizeReachesPTY: an initial size on Attach plus a later Resize both ++// reach the fakePTY.Resize via MsgResize frames. ++func TestAttachResizeReachesPTY(t *testing.T) { ++ f := startServe(t, 302) ++ defer f.cancel() ++ ++ r := runtimeForFixture("sess", f) ++ // Attach with a birth size: the implementation sends an initial MsgResize. ++ s, err := r.Attach(context.Background(), nameHandle("sess"), 40, 132) ++ if err != nil { ++ t.Fatalf("Attach: %v", err) ++ } ++ defer s.Close() ++ ++ if err := s.Resize(50, 160); err != nil { ++ t.Fatalf("Resize: %v", err) ++ } ++ ++ // Poll for both resizes (birth + explicit) to arrive on the fakePTY. ++ deadline := time.Now().Add(2 * time.Second) ++ for time.Now().Before(deadline) { ++ f.pty.resizeMu.Lock() ++ n := len(f.pty.resizes) ++ var last ResizePayload ++ if n > 0 { ++ last = f.pty.resizes[n-1] ++ } ++ f.pty.resizeMu.Unlock() ++ if n >= 2 && last.Cols == 160 && last.Rows == 50 { ++ return ++ } ++ time.Sleep(10 * time.Millisecond) ++ } ++ f.pty.resizeMu.Lock() ++ defer f.pty.resizeMu.Unlock() ++ t.Fatalf("resizes did not reach pty as expected: %+v", f.pty.resizes) ++} ++ ++// TestAttachUnknownSession: Attach to a session with no resolvable addr errors. ++func TestAttachUnknownSession(t *testing.T) { ++ r := New(Options{}) ++ if _, err := r.Attach(context.Background(), nameHandle("nope"), 0, 0); err == nil { ++ t.Fatal("expected error attaching to unknown session") ++ } ++} ++ ++func nameHandle(id string) ports.RuntimeHandle { ++ return ports.RuntimeHandle{ID: id} ++} +diff --git a/backend/internal/adapters/runtime/conpty/runtime.go b/backend/internal/adapters/runtime/conpty/runtime.go +index 818967b..efedbe8 100644 +--- a/backend/internal/adapters/runtime/conpty/runtime.go ++++ b/backend/internal/adapters/runtime/conpty/runtime.go +@@ -1,27 +1,27 @@ +-// runtime.go - conpty Runtime adapter. Implements ports.Runtime (minus Attach, +-// which comes in B5). Drives sessions via the B3 pty-host over loopback TCP, +-// using the B1 protocol and the B2 registry for restart recovery. ++// runtime.go - conpty Runtime adapter. Implements ports.Runtime and ++// ports.Attacher (see attach.go). Drives sessions via the B3 pty-host over ++// loopback TCP, using the B1 protocol and the B2 registry for restart recovery. + package conpty + + import ( + "context" + "fmt" + "regexp" + "sync" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + ) + +-// Ensure Runtime satisfies the port at compile time. Attach is added in B5. ++// Ensure Runtime satisfies the port at compile time (Attach in attach.go). + var _ ports.Runtime = (*Runtime)(nil) + + // validSessionID matches agent-orchestrator's assertValidSessionId. + var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + + // hostSession is the in-memory state for a live pty-host connection. + type hostSession struct { + addr string + pid int + } +diff --git a/backend/internal/terminal/pty_unix.go b/backend/internal/adapters/runtime/ptyexec/spawn_unix.go +similarity index 66% +rename from backend/internal/terminal/pty_unix.go +rename to backend/internal/adapters/runtime/ptyexec/spawn_unix.go +index c613bbd..e7d6d2c 100644 +--- a/backend/internal/terminal/pty_unix.go ++++ b/backend/internal/adapters/runtime/ptyexec/spawn_unix.go +@@ -1,37 +1,42 @@ + //go:build !windows + +-package terminal ++// Package ptyexec spawns a local PTY around an attach CLI (tmux/zellij) and ++// exposes it as a ports.Stream. It is the shared spawn the terminal layer used ++// to own directly; extracting it lets each runtime adapter back its Attach with ++// the same creack/pty (unix) or go-pty ConPTY (windows) plumbing. ++package ptyexec + + import ( + "context" + "errors" + "os" + "os/exec" + "sync" + "syscall" + "time" + ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/creack/pty" + ) + +-// defaultSpawn starts argv on a real PTY via creack/pty, sized rows×cols from +-// birth when a size is known: `zellij attach` reads the tty size once at +-// startup, and a post-spawn TIOCSWINSZ depends on SIGWINCH delivery that can +-// race the client installing its handler — StartWithSize makes the first read +-// correct by construction. env, when non-nil, replaces the inherited +-// environment (mirrors exec.Cmd.Env semantics). ctx cancellation closes the PTY +-// through the same graceful detach path as an explicit client close. Windows uses +-// a stub (see pty_windows.go) until a ConPTY path is added. +-func defaultSpawn(ctx context.Context, argv, env []string, rows, cols uint16) (ptyProcess, error) { ++// Spawn starts argv on a real PTY via creack/pty, sized rows×cols from birth ++// when a size is known: an attach client reads the tty size once at startup, and ++// a post-spawn TIOCSWINSZ depends on SIGWINCH delivery that can race the client ++// installing its handler — StartWithSize makes the first read correct by ++// construction. env, when non-nil, replaces the inherited environment (mirrors ++// exec.Cmd.Env semantics). ctx cancellation closes the PTY through the same ++// graceful detach path as an explicit client close. Windows uses a ConPTY path ++// (see spawn_windows.go). ++func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) { + if len(argv) == 0 { +- return nil, errors.New("terminal: empty attach command") ++ return nil, errors.New("ptyexec: empty attach command") + } + if err := ctx.Err(); err != nil { + return nil, err + } + cmd := exec.Command(argv[0], argv[1:]...) + if env != nil { + cmd.Env = env + } + var f *os.File + var err error +@@ -58,42 +63,42 @@ type creackPTY struct { + closeErr error + } + + func (p *creackPTY) Read(b []byte) (int, error) { return p.f.Read(b) } + func (p *creackPTY) Write(b []byte) (int, error) { return p.f.Write(b) } + + func (p *creackPTY) Resize(rows, cols uint16) error { + err := pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols}) + // Always follow with an explicit SIGWINCH: the kernel only raises one when + // the size actually changed, so a re-asserted (identical) grid would never +- // reach a zellij client that missed or lost the original signal — the ++ // reach an attach client that missed or lost the original signal — the + // session would stay laid out for a stale size, with no repaint until the + // next real change (the frontend re-sends its grid after each resize burst + // for exactly this self-heal; see useTerminalSession). The client re-reads + // the tty and re-reports to its server; when already in sync it's a no-op. + if p.cmd.Process != nil { + _ = p.cmd.Process.Signal(syscall.SIGWINCH) + } + return err + } + + // detachGrace is how long Close waits for a SIGTERM'd attach process to exit +-// on its own before falling back to SIGKILL. A zellij client that is being ++// on its own before falling back to SIGKILL. An attach client that is being + // drained detaches in ~50ms; the grace only runs out for a wedged process. + const detachGrace = 250 * time.Millisecond + + // Close stops the attach process and releases the PTY. + // +-// SIGTERM first, SIGKILL as fallback: a SIGTERM'd `zellij attach` deregisters +-// itself from the zellij server before exiting, while a SIGKILL'd one leaves ++// SIGTERM first, SIGKILL as fallback: a SIGTERM'd attach client deregisters ++// itself from its mux server before exiting, while a SIGKILL'd one leaves + // deregistration to the server noticing the dead socket. A dead-but-registered +-// client pins the session's size (zellij sizes a session to its smallest ++// client pins the session's size (a mux sizes a session to its smallest + // client), so the next attach renders for the ghost's grid — the "terminal + // doesn't repaint to the new size" desync. The master stays open through the + // grace so the run loop's copyOut keeps draining the client's shutdown output + // (a blocked tty write would stall the graceful exit past the grace). + // + // It is idempotent: both the attachment run loop (after copyOut returns) and + // attachment.close (via closeTerminal, conn cleanup, or Manager.Close) call + // Close on the same PTY, and cmd.Wait must run exactly once. A second + // concurrent Wait on the same process blocks forever, deadlocking daemon + // shutdown when a terminal is still attached. +diff --git a/backend/internal/terminal/pty_unix_test.go b/backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go +similarity index 89% +rename from backend/internal/terminal/pty_unix_test.go +rename to backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go +index 9a8bd9a..a0afefa 100644 +--- a/backend/internal/terminal/pty_unix_test.go ++++ b/backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go +@@ -1,53 +1,53 @@ + //go:build !windows + +-package terminal ++package ptyexec + + import ( + "context" + "strings" + "testing" + "time" + ) + + // TestCreackPTYCloseIsIdempotent guards the shutdown deadlock: the session run + // loop and session.close both call Close on the same PTY, so cmd.Wait must run + // exactly once. Without the sync.Once a second Wait blocks forever, so this test + // would hang (caught by the watchdog) rather than fail. + func TestCreackPTYCloseIsIdempotent(t *testing.T) { +- p, err := defaultSpawn(context.Background(), []string{"/bin/sh", "-c", "sleep 30"}, nil, 0, 0) ++ p, err := Spawn(context.Background(), []string{"/bin/sh", "-c", "sleep 30"}, nil, 0, 0) + if err != nil { + t.Fatalf("spawn: %v", err) + } + + done := make(chan struct{}) + go func() { + _ = p.Close() + _ = p.Close() // second close must not block on a second cmd.Wait + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("creackPTY.Close did not return: double Close deadlocked on cmd.Wait") + } + } + + // TestCreackPTYResizeSignalsOnIdenticalSize guards the resize self-heal: the + // kernel only raises SIGWINCH when TIOCSWINSZ actually changes the size, so a +-// re-asserted (identical) grid relies on Resize's explicit signal. A zellij ++// re-asserted (identical) grid relies on Resize's explicit signal. An attach + // client that lost the original update would otherwise keep its server laid + // out for a stale size forever — the "terminal doesn't repaint after resizing + // the pane" desync. + func TestCreackPTYResizeSignalsOnIdenticalSize(t *testing.T) { +- p, err := defaultSpawn(context.Background(), ++ p, err := Spawn(context.Background(), + []string{"/bin/sh", "-c", `trap 'echo WINCHED' WINCH; while :; do sleep 0.05; done`}, nil, 0, 0) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Close() + + // Give the shell a beat to install the trap, then resize twice to the SAME + // size. The first call changes the size (fresh PTYs start at 0x0) and the + // second is identical — only the explicit signal can deliver it. + time.Sleep(200 * time.Millisecond) +@@ -73,23 +73,23 @@ func TestCreackPTYResizeSignalsOnIdenticalSize(t *testing.T) { + if err != nil { + break + } + } + t.Fatalf("expected 2 WINCHED traps (one per Resize, including the identical one), got output: %q", out.String()) + } + + // TestCreackPTYSpawnsAtRequestedSize: the child must see the requested grid on + // its very first TIOCGWINSZ, with no SIGWINCH involved — sizing after exec + // races the client installing its WINCH handler (a missed signal strands the +-// zellij session at the previous client's size). ++// session at the previous client's size). + func TestCreackPTYSpawnsAtRequestedSize(t *testing.T) { +- p, err := defaultSpawn(context.Background(), []string{"/bin/sh", "-c", "stty size"}, nil, 40, 140) ++ p, err := Spawn(context.Background(), []string{"/bin/sh", "-c", "stty size"}, nil, 40, 140) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Close() + + deadline := time.Now().Add(5 * time.Second) + var out strings.Builder + buf := make([]byte, 4096) + for time.Now().Before(deadline) { + n, readErr := p.Read(buf) +@@ -100,40 +100,40 @@ func TestCreackPTYSpawnsAtRequestedSize(t *testing.T) { + } + } + if readErr != nil { + break + } + } + t.Fatalf("child did not see the spawn size 40x140, got output: %q", out.String()) + } + + // TestCreackPTYCloseTermsBeforeKill: Close must give the attach process a +-// chance to exit on SIGTERM (a zellij client deregisters from its server on ++// chance to exit on SIGTERM (an attach client deregisters from its server on + // SIGTERM; a straight SIGKILL leaves a ghost client that pins the session's + // size), and must still return promptly for a process that ignores SIGTERM. + func TestCreackPTYCloseTermsBeforeKill(t *testing.T) { + t.Run("cooperative process exits within the grace", func(t *testing.T) { +- p, err := defaultSpawn(context.Background(), ++ p, err := Spawn(context.Background(), + []string{"/bin/sh", "-c", `trap 'exit 0' TERM; while :; do sleep 0.05; done`}, nil, 0, 0) + if err != nil { + t.Fatalf("spawn: %v", err) + } + time.Sleep(200 * time.Millisecond) // let the trap install + start := time.Now() + _ = p.Close() + if elapsed := time.Since(start); elapsed >= detachGrace { + t.Fatalf("Close took %v: SIGTERM path did not let a cooperative process exit before the kill grace", elapsed) + } + }) + + t.Run("TERM-ignoring process is killed after the grace", func(t *testing.T) { +- p, err := defaultSpawn(context.Background(), ++ p, err := Spawn(context.Background(), + []string{"/bin/sh", "-c", `trap '' TERM; while :; do sleep 0.05; done`}, nil, 0, 0) + if err != nil { + t.Fatalf("spawn: %v", err) + } + time.Sleep(200 * time.Millisecond) + done := make(chan struct{}) + go func() { + _ = p.Close() + close(done) + }() +diff --git a/backend/internal/terminal/pty_windows.go b/backend/internal/adapters/runtime/ptyexec/spawn_windows.go +similarity index 70% +rename from backend/internal/terminal/pty_windows.go +rename to backend/internal/adapters/runtime/ptyexec/spawn_windows.go +index 5a83400..8f19f55 100644 +--- a/backend/internal/terminal/pty_windows.go ++++ b/backend/internal/adapters/runtime/ptyexec/spawn_windows.go +@@ -1,38 +1,39 @@ + //go:build windows + +-package terminal ++package ptyexec + + import ( + "context" + "errors" + "sync" + "time" + ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" + winpty "github.com/aymanbagabas/go-pty" + ) + + // detachGrace mirrors the Unix value: how long Close waits for the attach + // process to exit on its own (closing the ConPTY surfaces as EOF on the + // child's stdin) before falling back to Kill. + const detachGrace = 250 * time.Millisecond + +-// defaultSpawn starts argv on a Windows ConPTY and exposes the console pipes +-// through the same ptyProcess interface used by the Unix creack/pty path. +-// go-pty creates the pseudo-console at 80x25 internally, so we only Resize +-// when the caller actually has a grid (mirroring StartWithSize on Unix). +-// env, when non-nil, replaces the inherited environment via Win32's native +-// CreateProcess env block (mirrors exec.Cmd.Env semantics) — this is how a +-// per-session ZELLIJ_SOCKET_DIR reaches the zellij attach client. +-func defaultSpawn(ctx context.Context, argv []string, env []string, rows, cols uint16) (ptyProcess, error) { ++// Spawn starts argv on a Windows ConPTY and exposes the console pipes through ++// the same ports.Stream interface used by the Unix creack/pty path. go-pty ++// creates the pseudo-console at 80x25 internally, so we only Resize when the ++// caller actually has a grid (mirroring StartWithSize on Unix). env, when ++// non-nil, replaces the inherited environment via Win32's native CreateProcess ++// env block (mirrors exec.Cmd.Env semantics) — this is how a per-session ++// ZELLIJ_SOCKET_DIR reaches the zellij attach client. ++func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) { + if len(argv) == 0 { +- return nil, errors.New("terminal: empty attach command") ++ return nil, errors.New("ptyexec: empty attach command") + } + pty, err := winpty.New() + if err != nil { + return nil, err + } + if rows > 0 && cols > 0 { + if err := pty.Resize(int(cols), int(rows)); err != nil { + _ = pty.Close() + return nil, err + } +diff --git a/backend/internal/terminal/pty_windows_test.go b/backend/internal/adapters/runtime/ptyexec/spawn_windows_test.go +similarity index 73% +rename from backend/internal/terminal/pty_windows_test.go +rename to backend/internal/adapters/runtime/ptyexec/spawn_windows_test.go +index ee8c7d6..7dab0ad 100644 +--- a/backend/internal/terminal/pty_windows_test.go ++++ b/backend/internal/adapters/runtime/ptyexec/spawn_windows_test.go +@@ -1,68 +1,70 @@ + //go:build windows + +-package terminal ++package ptyexec + + import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" + ) + +-func TestDefaultSpawnWindowsStreamsOutput(t *testing.T) { +- p, err := defaultSpawn(context.Background(), []string{"cmd.exe", "/D", "/Q", "/K"}, nil, 24, 80) ++func TestSpawnWindowsStreamsOutput(t *testing.T) { ++ p, err := Spawn(context.Background(), []string{"cmd.exe", "/D", "/Q", "/K"}, nil, 24, 80) + if err != nil { +- t.Fatalf("defaultSpawn: %v", err) ++ t.Fatalf("Spawn: %v", err) + } + defer p.Close() + if _, err := p.Write([]byte("echo AO_CONPTY_OK\r\n")); err != nil { + t.Fatalf("write PTY: %v", err) + } + + out := readPTYUntil(t, p, "AO_CONPTY_OK", 5*time.Second) + if !strings.Contains(out, "AO_CONPTY_OK") { + t.Fatalf("output %q does not contain marker", out) + } + } + +-func TestDefaultSpawnWindowsRejectsEmptyCommand(t *testing.T) { +- _, err := defaultSpawn(context.Background(), nil, nil, 0, 0) ++func TestSpawnWindowsRejectsEmptyCommand(t *testing.T) { ++ _, err := Spawn(context.Background(), nil, nil, 0, 0) + if err == nil || !strings.Contains(err.Error(), "empty attach command") { + t.Fatalf("expected empty attach command error, got %v", err) + } + } + + func TestConPTYCloseIsIdempotent(t *testing.T) { +- p, err := defaultSpawn(context.Background(), []string{"cmd.exe", "/D", "/Q", "/K"}, nil, 24, 80) ++ p, err := Spawn(context.Background(), []string{"cmd.exe", "/D", "/Q", "/K"}, nil, 24, 80) + if err != nil { +- t.Fatalf("defaultSpawn: %v", err) ++ t.Fatalf("Spawn: %v", err) + } + + done := make(chan struct{}) + go func() { + _ = p.Close() + _ = p.Close() + close(done) + }() + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("Close did not return") + } + } + +-func readPTYUntil(t *testing.T, p ptyProcess, marker string, timeout time.Duration) string { ++func readPTYUntil(t *testing.T, p ports.Stream, marker string, timeout time.Duration) string { + t.Helper() + type result struct { + out string + err error + } + results := make(chan result, 1) + go func() { + var buf bytes.Buffer + tmp := make([]byte, 4096) + for { +diff --git a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go +index e6ca4a0..8e486db 100644 +--- a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go ++++ b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go +@@ -8,26 +8,27 @@ import ( + "os" + "runtime" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + ) + + // Runtime is the union interface that both tmux and zellij satisfy. + // It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods +-// the daemon wires directly. ++// the daemon wires directly, including ports.Attacher (Attach) so the terminal ++// layer can open a Stream against the selected runtime. + type Runtime interface { + ports.Runtime // Create, Destroy, IsAlive ++ ports.Attacher + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error + GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) +- AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) + } + + // Compile-time assertions: both adapters must implement the union interface. + var _ Runtime = (*tmux.Runtime)(nil) + var _ Runtime = (*zellij.Runtime)(nil) + + // New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. + // log is used only for the Windows zellij socket-dir warning; nil is safe. + func New(log *slog.Logger) Runtime { + if runtime.GOOS != "windows" { +diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go +index 90235ca..4cd00e9 100644 +--- a/backend/internal/adapters/runtime/tmux/tmux.go ++++ b/backend/internal/adapters/runtime/tmux/tmux.go +@@ -8,20 +8,21 @@ import ( + "errors" + "fmt" + "os" + "os/exec" + "regexp" + "sort" + "strings" + "time" + "unicode/utf8" + ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/ptyexec" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + ) + + const ( + defaultTimeout = 5 * time.Second + defaultChunkBytes = 16 * 1024 + ) + + var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) +@@ -41,20 +42,21 @@ type Options struct { + // CLI. It implements ports.Runtime. + type Runtime struct { + binary string + shell string + timeout time.Duration + chunkSize int + runner runner + } + + var _ ports.Runtime = (*Runtime)(nil) ++var _ ports.Attacher = (*Runtime)(nil) + + type runner interface { + Run(ctx context.Context, env []string, name string, args ...string) ([]byte, 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 = append(append([]string(nil), os.Environ()...), env...) +@@ -212,24 +214,35 @@ func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lin + if lines <= 0 { + return "", errors.New("tmux runtime: lines must be positive") + } + out, err := r.run(ctx, capturePaneArgs(id, lines)...) + if err != nil { + return "", fmt.Errorf("tmux runtime: capture output %s: %w", id, err) + } + return tailLines(trimTrailingBlankLines(string(out)), lines), nil + } + +-// AttachCommand returns the argv a human runs to attach their terminal to the ++// Attach opens a fresh attach Stream by spawning `tmux attach-session` on a ++// local PTY, sized rows x cols from birth when known. ctx cancellation closes ++// the PTY. ++func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { ++ argv, env, err := r.attachCommand(handle) ++ if err != nil { ++ return nil, err ++ } ++ return ptyexec.Spawn(ctx, argv, env, rows, cols) ++} ++ ++// attachCommand returns the argv a human runs to attach their terminal to the + // session. No per-session env block is needed for tmux (unlike zellij's Windows + // ConPTY path), so env is always nil. +-func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { ++func (r *Runtime) attachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { + id, err := handleID(handle) + if err != nil { + return nil, nil, err + } + return []string{r.binary, "attach-session", "-t", id}, nil, nil + } + + // run wraps runner.Run with a per-call timeout context. + func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { + cmdCtx, cancel := context.WithTimeout(ctx, r.timeout) +diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go +index 07003d1..a5979b7 100644 +--- a/backend/internal/adapters/runtime/tmux/tmux_test.go ++++ b/backend/internal/adapters/runtime/tmux/tmux_test.go +@@ -520,36 +520,36 @@ func TestGetOutputArgs(t *testing.T) { + } + if got, want := fr.calls[0].args, capturePaneArgs("sess-1", 10); !reflect.DeepEqual(got, want) { + t.Fatalf("capture-pane args = %#v, want %#v", got, want) + } + } + + // -- AttachCommand tests -- + + func TestAttachCommandReturnsExpectedArgv(t *testing.T) { + r := New(Options{Binary: "/usr/bin/tmux", Timeout: time.Second}) +- argv, env, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1"}) ++ argv, env, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1"}) + if err != nil { + t.Fatalf("AttachCommand: %v", err) + } + want := []string{"/usr/bin/tmux", "attach-session", "-t", "sess-1"} + if !reflect.DeepEqual(argv, want) { + t.Fatalf("argv = %#v, want %#v", argv, want) + } + if env != nil { + t.Fatalf("env = %#v, want nil", env) + } + } + + func TestAttachCommandRejectsInvalidHandle(t *testing.T) { + r := New(Options{}) +- _, _, err := r.AttachCommand(ports.RuntimeHandle{ID: ""}) ++ _, _, err := r.attachCommand(ports.RuntimeHandle{ID: ""}) + if err == nil { + t.Fatal("AttachCommand empty handle: got nil, want error") + } + } + + // -- commandError tests -- + + func TestCommandErrorUnwraps(t *testing.T) { + base := errors.New("base") + err := commandError{err: base, output: "details"} +diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go +index 03a1f93..d7a8451 100644 +--- a/backend/internal/adapters/runtime/zellij/zellij.go ++++ b/backend/internal/adapters/runtime/zellij/zellij.go +@@ -11,20 +11,21 @@ import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "time" + "unicode/utf8" + ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/ptyexec" + "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" +@@ -62,20 +63,21 @@ type Runtime struct { + timeout time.Duration + shell string + socketDir string + configDir string + chunkSize int + launcher string + runner runner + } + + var _ ports.Runtime = (*Runtime)(nil) ++var _ ports.Attacher = (*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 "" +@@ -364,24 +366,36 @@ func deleteSessionMissingOutput(out string) bool { + 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 ++// Attach opens a fresh attach Stream by spawning the zellij attach client on a ++// local PTY, sized rows x cols from birth when known. On Windows the per-session ++// env block (ZELLIJ_SOCKET_DIR) is applied to the child. ctx cancellation closes ++// the PTY. ++func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { ++ argv, env, err := r.attachCommand(handle) ++ if err != nil { ++ return nil, err ++ } ++ return ptyexec.Spawn(ctx, argv, env, rows, cols) ++} ++ ++// 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) { ++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 + } + +diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go +index 657b575..1a6d6a8 100644 +--- a/backend/internal/adapters/runtime/zellij/zellij_test.go ++++ b/backend/internal/adapters/runtime/zellij/zellij_test.go +@@ -428,21 +428,21 @@ func TestCreateClearsStaleSessionBeforeCreating(t *testing.T) { + if got, want := fr.calls[1].args, []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("delete args = %#v, want %#v", got, want) + } + if got := fr.calls[2].args[:3]; !reflect.DeepEqual(got, []string{"attach", "--create-background", "sess-1"}) { + t.Fatalf("create args prefix = %#v", got) + } + } + + func TestAttachCommandUsesEmbeddedClientOptions(t *testing.T) { + r := New(Options{}) +- args, _, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) ++ args, _, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) + if err != nil { + t.Fatalf("AttachCommand: %v", err) + } + embeddedOptions := []string{ + "--pane-frames", "false", + "--mouse-mode", "true", + "--advanced-mouse-actions", "false", + "--mouse-hover-effects", "false", + "--focus-follows-mouse", "false", + "--mouse-click-through", "false", +@@ -459,21 +459,21 @@ func TestAttachCommandUsesEmbeddedClientOptions(t *testing.T) { + } + want := append(expectedAttachEnvPrefix(), r.binary, "attach", "sess-1", "options") + want = append(want, embeddedOptions...) + if !reflect.DeepEqual(args, want) { + t.Fatalf("AttachCommand = %#v, want %#v", args, want) + } + } + + func TestAttachCommandUsesSocketDir(t *testing.T) { + r := New(Options{SocketDir: "/tmp/zj"}) +- args, _, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) ++ args, _, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) + if err != nil { + t.Fatalf("AttachCommand: %v", err) + } + if runtime.GOOS == "windows" { + if got, want := args[0], r.binary; got != want { + t.Fatalf("attach binary = %q, want %q", got, want) + } + return + } + if got, want := args[:6], []string{"env", "-u", "NO_COLOR", "TERM=xterm-256color", "COLORTERM=truecolor", "ZELLIJ_SOCKET_DIR=/tmp/zj"}; !reflect.DeepEqual(got, want) { +diff --git a/backend/internal/httpd/terminal_mux_test.go b/backend/internal/httpd/terminal_mux_test.go +index e0c9929..7114a06 100644 +--- a/backend/internal/httpd/terminal_mux_test.go ++++ b/backend/internal/httpd/terminal_mux_test.go +@@ -6,38 +6,39 @@ import ( + "net/http/httptest" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/ptyexec" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/terminal" + ) + +-// stubSource attaches a throwaway shell command instead of a real Zellij pane, so ++// stubSource attaches a throwaway shell command instead of a real mux pane, so + // the /mux path exercises the genuine upgrade + wsjson + Serve + creack/pty flow +-// without needing Zellij. The pane reports alive until the first attach happens +-// (the mux refuses to attach to a dead pane), then dead, so the command's exit is +-// treated as the pane being gone (no re-attach). ++// without needing a runtime. The pane reports alive until the first attach ++// happens (the mux refuses to attach to a dead pane), then dead, so the ++// command's exit is treated as the pane being gone (no re-attach). + type stubSource struct { + argv []string + attached atomic.Bool + } + +-func (s *stubSource) AttachCommand(ports.RuntimeHandle) ([]string, []string, error) { ++func (s *stubSource) Attach(ctx context.Context, _ ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { + s.attached.Store(true) +- return s.argv, nil, nil ++ return ptyexec.Spawn(ctx, s.argv, nil, rows, cols) + } + + func (s *stubSource) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { + return !s.attached.Load(), nil + } + + type terminalMuxFrame struct { + Ch string `json:"ch"` + ID string `json:"id"` + Type string `json:"type"` +diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go +index 668da51..97683dc 100644 +--- a/backend/internal/ports/outbound.go ++++ b/backend/internal/ports/outbound.go +@@ -1,16 +1,17 @@ + package ports + + import ( + "context" + "errors" + "fmt" ++ "io" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + ) + + // PRWriter records the PR facts a PR observation carries. The pr table's own DB + // triggers emit the CDC; this just writes the rows. + type PRWriter interface { + // WritePR persists a full PR observation — scalar facts, check runs, and the + // replacement comment set — in one transaction, so the rows and the CDC + // events they emit are all-or-nothing. +@@ -92,20 +93,34 @@ type RuntimeConfig struct { + Argv []string + Env map[string]string + } + + // RuntimeHandle identifies a live runtime instance. Its ID is opaque outside + // the concrete runtime adapter. + type RuntimeHandle struct { + ID string + } + ++// Stream is one live terminal attach: PTY-like bytes plus resize. Returned ++// already-open by a Runtime's Attach. tmux/zellij back it with a local PTY around ++// their attach CLI; conpty backs it with a loopback connection to the pty-host. ++type Stream interface { ++ io.ReadWriteCloser ++ Resize(rows, cols uint16) error ++} ++ ++// Attacher opens a fresh attach Stream for a session handle, sized rows x cols from ++// birth (0 means size not yet known). ctx cancellation must terminate the stream. ++type Attacher interface { ++ Attach(ctx context.Context, handle RuntimeHandle, rows, cols uint16) (Stream, error) ++} ++ + // The Agent port and its supporting types live in agent.go. + + // Workspace is the isolated checkout an agent works in (a git worktree or clone). + type Workspace interface { + Create(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) + Destroy(ctx context.Context, info WorkspaceInfo) error + Restore(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) + } + + // Workspace-level sentinels surfaced through Create/Restore/Destroy so callers +diff --git a/backend/internal/terminal/attachment.go b/backend/internal/terminal/attachment.go +index e45bca6..3526898 100644 +--- a/backend/internal/terminal/attachment.go ++++ b/backend/internal/terminal/attachment.go +@@ -1,114 +1,90 @@ + package terminal + + import ( + "context" + "errors" +- "io" + "log/slog" + "sync" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + ) + +-// PTYSource is what a terminal needs from the runtime: the argv that attaches a +-// PTY to a session's pane (plus any env that argv needs but is not in the +-// daemon's process env — e.g. a per-session ZELLIJ_SOCKET_DIR on Windows), +-// and a liveness check used to decide whether a dropped PTY should be +-// re-attached or treated as a clean exit. The Zellij runtime adapter +-// satisfies this via AttachCommand/IsAlive; the interface lives here, next to +-// its only consumer, so terminal does not depend on a concrete adapter. +-type PTYSource interface { +- AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) ++// Source is what the terminal needs from the runtime: open an attach Stream and ++// a liveness check used to decide whether a dropped Stream should be re-attached ++// or treated as a clean exit. The runtime adapters (tmux/zellij/conpty) satisfy ++// it via Attach/IsAlive; the interface lives here, next to its only consumer, so ++// terminal does not depend on a concrete adapter. ++type Source interface { ++ ports.Attacher + IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) + } + +-// ptyProcess is a started PTY-backed attach process. It is the injection seam +-// that keeps the attach loop testable without a real process: unit tests supply +-// a scripted in-memory implementation; production uses a creack/pty-backed one +-// (see pty_unix.go). +-type ptyProcess interface { +- io.ReadWriteCloser +- Resize(rows, cols uint16) error +-} +- +-// spawnFunc starts a PTY for argv, sized rows×cols from the start (zero means +-// no size was recorded yet — kernel default). Spawning at the client's grid +-// matters: the attach process reads the tty size once at startup, and sizing +-// the PTY only after exec relies on SIGWINCH delivery that can race the +-// process installing its handler — a missed signal leaves the zellij client +-// laid out for a stale size. env, when non-nil, is the full env block for the +-// child (mirrors exec.Cmd.Env: nil means inherit). ctx cancellation must +-// terminate the process. +-type spawnFunc func(ctx context.Context, argv []string, env []string, rows, cols uint16) (ptyProcess, error) +- +-// reattach policy: a PTY that drops is re-attached while the underlying Zellij ++// reattach policy: a Stream that drops is re-attached while the underlying + // session is still alive, up to maxReattach consecutive failures. An attach that + // survived longer than reattachResetGrace before dropping resets the counter, so + // a long-lived pane that blips recovers but a tight crash-loop gives up. + const ( + defaultMaxReattach = 5 + defaultReattachResetTime = 5 * time.Second + ) + +-// attachment is ONE client's hold on a pane: a private `zellij attach` PTY +-// spawned per mux open, streaming to a single sink. Zellij is the multiplexer — +-// it owns the session's screen state and scrollback, and answers every fresh +-// attach with its init handshake (alt screen, bracketed paste, and other terminal +-// modes enabled by the embedded client options) followed by a faithful repaint. +-// That handshake is why the PTY is per-client and there is no server-side replay +-// buffer: a byte ring can replay recent output, but the one-time mode negotiation +-// at the head of the stream scrolls out of any bounded buffer. A fresh attach per +-// client makes Zellij re-send it, every time, by construction. ++// attachment is ONE client's hold on a pane: a private attach Stream opened per ++// mux open, streaming to a single sink. The runtime is the multiplexer — it owns ++// the session's screen state and scrollback, and answers every fresh attach with ++// its init handshake (alt screen, bracketed paste, scrollback replay) followed by ++// a faithful repaint. That handshake is why the Stream is per-client and there is ++// no terminal-layer replay buffer: a byte ring can replay recent output, but the ++// one-time mode negotiation at the head of the stream scrolls out of any bounded ++// buffer. A fresh attach per client makes the runtime re-send it, every time, by ++// construction. + // +-// onOpen fires once the attach PTY is actually ready to accept input. onData ++// onOpen fires once the attach Stream is actually ready to accept input. onData + // must not block: the WS layer funnels frames onto its own buffered writer. + // onExit fires at most once, when the attach loop gives up (runtime dead, + // attach failure cap) — never on close(). + type attachment struct { + id string + handle ports.RuntimeHandle +- src PTYSource +- spawn spawnFunc ++ src Source + log *slog.Logger + onOpen func() + onData func(data []byte) + onExit func() + + maxReattach int + resetGrace time.Duration + + mu sync.Mutex +- pty ptyProcess ++ pty ports.Stream + cancel context.CancelFunc + rows uint16 // last size the client asked for; re-applied on every attach + cols uint16 + closed bool + exited bool + opened bool + inputReady bool + pendingInput [][]byte + } + +-func newAttachment(id string, handle ports.RuntimeHandle, src PTYSource, spawn spawnFunc, onOpen func(), onData func([]byte), onExit func(), log *slog.Logger) *attachment { ++func newAttachment(id string, handle ports.RuntimeHandle, src Source, onOpen func(), onData func([]byte), onExit func(), log *slog.Logger) *attachment { + if log == nil { + log = slog.Default() + } + if onData == nil { + onData = func([]byte) {} + } + return &attachment{ + id: id, + handle: handle, + src: src, +- spawn: spawn, + log: log, + onOpen: onOpen, + onData: onData, + onExit: onExit, + maxReattach: defaultMaxReattach, + resetGrace: defaultReattachResetTime, + } + } + + // run drives attach → read-loop → re-attach until the pane exits cleanly, the +@@ -121,21 +97,21 @@ func (a *attachment) run(ctx context.Context) { + } + defer a.clearRunCancel(cancel) + + failures := 0 + for { + if a.shouldStop(ctx) { + return + } + + // Gate EVERY attach (including the first) on the runtime actually +- // being alive. `zellij attach` resurrects EXITED sessions — re-running ++ // being alive. A mux attach resurrects EXITED sessions — re-running + // the serialized agent command — so attaching to a dead handle would + // re-create a runtime the daemon already destroyed, outside lifecycle + // control. A definitive "not alive" is a clean exit. A probe ERROR is + // not proof of death: it retries with backoff up to the same + // consecutive-failure cap as attach failures. + alive, err := a.src.IsAlive(ctx, a.handle) + if a.shouldStop(ctx) { + return + } + if err != nil { +@@ -147,43 +123,35 @@ func (a *attachment) run(ctx context.Context) { + if !a.backoff(ctx, failures) { + return + } + continue + } + if !alive { + a.markExited() + return + } + +- argv, env, err := a.src.AttachCommand(a.handle) +- if a.shouldStop(ctx) { +- return +- } +- if err != nil { +- a.fail("attach command: " + err.Error()) +- return +- } + rows, cols := a.size() + if a.shouldStop(ctx) { + return + } +- p, err := a.spawn(ctx, argv, env, rows, cols) ++ p, err := a.src.Attach(ctx, a.handle, rows, cols) + if a.shouldStop(ctx) { + if p != nil { + _ = p.Close() + } + return + } + if err != nil { + failures++ + if failures > a.maxReattach { +- a.fail("spawn pty: " + err.Error()) ++ a.fail("attach: " + err.Error()) + return + } + if !a.backoff(ctx, failures) { + return + } + continue + } + + if !a.setPTY(p) { + _ = p.Close() +@@ -207,21 +175,21 @@ func (a *attachment) run(ctx context.Context) { + return + } + if !a.backoff(ctx, failures) { + return + } + a.log.Debug("terminal re-attaching", "id", a.id, "failures", failures) + } + } + + // copyOut pumps PTY output to the sink until the PTY closes or errors. +-func (a *attachment) copyOut(p ptyProcess) { ++func (a *attachment) copyOut(p ports.Stream) { + buf := make([]byte, 32*1024) + for { + n, err := p.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + a.onData(chunk) + } + if err != nil { + return +@@ -284,33 +252,33 @@ func (a *attachment) resize(rows, cols uint16) error { + a.rows, a.cols = rows, cols + pty := a.pty + a.mu.Unlock() + if pty == nil { + return nil + } + return pty.Resize(rows, cols) + } + + // size returns the client's last requested grid (zero before the first +-// open/resize recorded one). The spawn path reads it so the PTY starts at the +-// client's grid instead of the kernel default. ++// open/resize recorded one). The attach path reads it so the Stream starts at ++// the client's grid instead of the kernel default. + func (a *attachment) size() (rows, cols uint16) { + a.mu.Lock() + defer a.mu.Unlock() + return a.rows, a.cols + } + +-// setPTY publishes a freshly attached PTY and replays the client's last +-// requested size onto it (see resize) — the spawn already started at the size ++// setPTY publishes a freshly attached Stream and replays the client's last ++// requested size onto it (see resize) — the attach already started at the size + // read in run, but a resize frame can land between that read and registration +-// here; the replay (Setsize + explicit WINCH) converges the late case. +-func (a *attachment) setPTY(p ptyProcess) bool { ++// here; the replay (Resize) converges the late case. ++func (a *attachment) setPTY(p ports.Stream) bool { + a.mu.Lock() + if a.closed || a.exited { + a.mu.Unlock() + return false + } + a.pty = p + a.inputReady = false + rows, cols := a.rows, a.cols + shouldOpen := !a.opened + if shouldOpen { +@@ -338,31 +306,31 @@ func (a *attachment) setPTY(p ptyProcess) bool { + + for _, chunk := range pending { + if _, err := p.Write(chunk); err != nil { + a.fail("flush pending input: " + err.Error()) + return false + } + } + } + } + +-func (a *attachment) clearPTY(p ptyProcess) { ++func (a *attachment) clearPTY(p ports.Stream) { + a.mu.Lock() + if a.pty == p { + a.pty = nil + a.inputReady = false + } + a.mu.Unlock() + } + + // close detaches this client: stop re-attaching and kill the attach PTY. It +-// never touches the Zellij session itself, which the zellij server keeps alive ++// never touches the runtime session itself, which the mux server keeps alive + // for other clients. + func (a *attachment) close() { + a.mu.Lock() + if a.closed { + a.mu.Unlock() + return + } + a.closed = true + pty := a.pty + a.pty = nil +diff --git a/backend/internal/terminal/attachment_integration_test.go b/backend/internal/terminal/attachment_integration_test.go +index c2eb899..452362f 100644 +--- a/backend/internal/terminal/attachment_integration_test.go ++++ b/backend/internal/terminal/attachment_integration_test.go +@@ -36,21 +36,21 @@ func TestAttachmentStreamsRealZellijPane(t *testing.T) { + SessionID: domain.SessionID(name), + WorkspacePath: t.TempDir(), + Argv: []string{"sh", "-lc", "printf AO_READY\\n; exec sh -i"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) }) + + var got safeBytes +- a := newAttachment(name, handle, rt, defaultSpawn, nil, got.add, nil, testLogger()) ++ a := newAttachment(name, handle, rt, nil, got.add, nil, testLogger()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.run(ctx) + + // Type a unique marker and expect it echoed back through the PTY. + eventually(t, 3*time.Second, func() bool { return a.write([]byte("echo AO_MARKER_42\n")) == nil }) + eventually(t, 5*time.Second, func() bool { return strings.Contains(got.string(), "AO_MARKER_42") }) + + // A fresh attach must carry zellij's alt-screen init handshake. Mouse + // reporting is deliberately disabled for AO's embedded client, so this test +@@ -91,21 +91,21 @@ func TestAttachmentReattachAdoptsNewSize(t *testing.T) { + Argv: []string{"sh", "-lc", "printf AO_READY\\n; exec sh -i"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) }) + + attachAt := func(rows, cols uint16) (*attachment, *safeBytes, <-chan struct{}, context.CancelFunc) { + var got safeBytes + opened := make(chan struct{}) +- a := newAttachment(name, handle, rt, defaultSpawn, func() { close(opened) }, got.add, nil, testLogger()) ++ a := newAttachment(name, handle, rt, func() { close(opened) }, got.add, nil, testLogger()) + if err := a.resize(rows, cols); err != nil { + t.Fatalf("record size: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + go a.run(ctx) + return a, &got, opened, cancel + } + + // Client A at 115x37: wait for the pane shell, then detach. + a, _, openedA, cancelA := attachAt(37, 115) +diff --git a/backend/internal/terminal/attachment_test.go b/backend/internal/terminal/attachment_test.go +index a085194..634edfc 100644 +--- a/backend/internal/terminal/attachment_test.go ++++ b/backend/internal/terminal/attachment_test.go +@@ -6,108 +6,107 @@ import ( + "log/slog" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + ) + + func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +-func newTestAttachment(src PTYSource, spawn spawnFunc, onData func([]byte), onExit func()) *attachment { +- return newTestAttachmentWithOpen(src, spawn, nil, onData, onExit) ++func newTestAttachment(src Source, onData func([]byte), onExit func()) *attachment { ++ return newTestAttachmentWithOpen(src, nil, onData, onExit) + } + +-func newTestAttachmentWithOpen(src PTYSource, spawn spawnFunc, onOpen func(), onData func([]byte), onExit func()) *attachment { +- return newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, src, spawn, onOpen, onData, onExit, testLogger()) ++func newTestAttachmentWithOpen(src Source, onOpen func(), onData func([]byte), onExit func()) *attachment { ++ return newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, src, onOpen, onData, onExit, testLogger()) + } + +-func currentPTY(a *attachment) ptyProcess { ++func currentPTY(a *attachment) ports.Stream { + a.mu.Lock() + defer a.mu.Unlock() + return a.pty + } + + func TestAttachmentStreamsOutputToSink(t *testing.T) { +- src := &fakeSource{alive: true} + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} ++ src := &fakeSource{alive: true, spawner: sp} + + var sink safeBytes +- a := newTestAttachment(src, sp.spawn, sink.add, nil) ++ a := newTestAttachment(src, sink.add, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.run(ctx) + + pty.push([]byte("hello")) + eventually(t, time.Second, func() bool { return sink.string() == "hello" }) + } + + func TestAttachmentWriteAndResizeReachPTY(t *testing.T) { +- src := &fakeSource{alive: true} + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} +- a := newTestAttachment(src, sp.spawn, nil, nil) ++ src := &fakeSource{alive: true, spawner: sp} ++ a := newTestAttachment(src, nil, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.run(ctx) + + eventually(t, time.Second, func() bool { return a.write([]byte("ls\n")) == nil }) + eventually(t, time.Second, func() bool { return string(pty.writtenBytes()) == "ls\n" }) + + if err := a.resize(24, 80); err != nil { + t.Fatalf("resize: %v", err) + } + eventually(t, time.Second, func() bool { + rs := pty.resizeCalls() + return len(rs) == 1 && rs[0] == [2]uint16{24, 80} + }) + } + + func TestAttachmentSignalsOpenOnlyAfterPTYIsPublished(t *testing.T) { +- src := &fakeSource{alive: true} + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} ++ src := &fakeSource{alive: true, spawner: sp} + + opened := make(chan bool, 1) + var a *attachment +- a = newTestAttachmentWithOpen(src, sp.spawn, func() { ++ a = newTestAttachmentWithOpen(src, func() { + opened <- currentPTY(a) == pty + }, nil, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.run(ctx) + + select { + case sawPTY := <-opened: + if !sawPTY { + t.Fatal("open callback fired before the PTY was published") + } + case <-time.After(time.Second): + t.Fatal("open callback did not fire") + } + } + + func TestAttachmentBuffersInputUntilPTYReady(t *testing.T) { +- src := &fakeSource{alive: true} + pty := newFakePTY() + spawnStarted := make(chan struct{}) + releaseSpawn := make(chan struct{}) +- spawn := func(context.Context, []string, []string, uint16, uint16) (ptyProcess, error) { ++ src := &fakeSource{alive: true, attachFn: func(context.Context, uint16, uint16) (ports.Stream, error) { + close(spawnStarted) + <-releaseSpawn + return pty, nil +- } +- a := newTestAttachment(src, spawn, nil, nil) ++ }} ++ a := newTestAttachment(src, nil, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.run(ctx) + + select { + case <-spawnStarted: + case <-time.After(time.Second): + t.Fatal("spawn was not reached") + } +@@ -116,51 +115,51 @@ func TestAttachmentBuffersInputUntilPTYReady(t *testing.T) { + } + close(releaseSpawn) + + eventually(t, time.Second, func() bool { return string(pty.writtenBytes()) == "hello\n" }) + } + + // A size requested before the PTY exists (the open frame's cols/rows, or a + // resize racing the attach) must not be lost: the attach applies it the moment + // the PTY is up, instead of leaving the pane at the kernel default grid. + func TestAttachmentAppliesRequestedSizeOnAttach(t *testing.T) { +- src := &fakeSource{alive: true} + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} +- a := newTestAttachment(src, sp.spawn, nil, nil) ++ src := &fakeSource{alive: true, spawner: sp} ++ a := newTestAttachment(src, nil, nil) + + if err := a.resize(30, 100); err != nil { + t.Fatalf("resize before attach: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.run(ctx) + + eventually(t, time.Second, func() bool { + rs := pty.resizeCalls() + return len(rs) == 1 && rs[0] == [2]uint16{30, 100} + }) + } + +-// The PTY must be SPAWNED at the recorded grid, not sized after exec: the +-// attach process (zellij) reads the tty size once at startup, and a post-spawn +-// TIOCSWINSZ depends on SIGWINCH delivery that can race the client installing +-// its handler — a missed signal left the session laid out for the previous +-// client's size (the "terminal doesn't repaint after a resize" desync). Also +-// covers re-attach: a later resize must reach the NEXT spawn, not the first +-// grid forever. ++// The Stream must be OPENED at the recorded grid, not sized after attach: the ++// attach client reads the tty size once at startup, and a post-attach resize ++// depends on SIGWINCH delivery that can race the client installing its handler ++// — a missed signal left the session laid out for the previous client's size ++// (the "terminal doesn't repaint after a resize" desync). Also covers ++// re-attach: a later resize must reach the NEXT attach, not the first grid ++// forever. + func TestAttachmentSpawnsPTYAtRecordedSize(t *testing.T) { +- src := &fakeSource{alive: true} + first := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{first}} +- a := newTestAttachment(src, sp.spawn, nil, nil) ++ src := &fakeSource{alive: true, spawner: sp} ++ a := newTestAttachment(src, nil, nil) + a.resetGrace = time.Hour // keep the failure counter deterministic + + if err := a.resize(37, 115); err != nil { + t.Fatalf("resize before attach: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.run(ctx) + +@@ -176,103 +175,103 @@ func TestAttachmentSpawnsPTYAtRecordedSize(t *testing.T) { + } + _ = first.Close() + + eventually(t, time.Second, func() bool { + ss := sp.spawnSizes() + return len(ss) == 2 && ss[1] == [2]uint16{40, 148} + }) + } + + func TestAttachmentSkipsReattachOnCleanExit(t *testing.T) { +- src := &fakeSource{alive: true} // alive for the first attach + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} ++ src := &fakeSource{alive: true, spawner: sp} // alive for the first attach + + exited := make(chan struct{}) +- a := newTestAttachment(src, sp.spawn, nil, func() { close(exited) }) ++ a := newTestAttachment(src, nil, func() { close(exited) }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.run(ctx) + + eventually(t, time.Second, func() bool { return sp.calls() == 1 }) +- src.setAlive(false) // Zellij session gone -> no re-attach ++ src.setAlive(false) // runtime session gone -> no re-attach + pty.Close() // pane ends + select { + case <-exited: + case <-time.After(time.Second): + t.Fatal("expected exit callback after clean pane exit") + } + if got := sp.calls(); got != 1 { + t.Fatalf("expected exactly one attach, got %d", got) + } + } + +-// TestAttachmentNeverAttachesToDeadRuntime covers the resurrection bug: `zellij +-// attach` on a killed-but-cached session resurrects it, re-running the agent ++// TestAttachmentNeverAttachesToDeadRuntime covers the resurrection bug: a mux ++// attach on a killed-but-cached session resurrects it, re-running the agent + // command. An attachment whose runtime probes definitively dead must therefore +-// report exited WITHOUT ever spawning an attach PTY — even on the very first ++// report exited WITHOUT ever opening an attach Stream — even on the very first + // open (the original code only checked liveness on re-attach). + func TestAttachmentNeverAttachesToDeadRuntime(t *testing.T) { +- src := &fakeSource{alive: false} + sp := &fakeSpawner{} ++ src := &fakeSource{alive: false, spawner: sp} + + exited := make(chan struct{}) +- a := newTestAttachment(src, sp.spawn, nil, func() { close(exited) }) ++ a := newTestAttachment(src, nil, func() { close(exited) }) + + go a.run(context.Background()) + select { + case <-exited: + case <-time.After(time.Second): + t.Fatal("expected exit when runtime is dead before first attach") + } + if got := sp.calls(); got != 0 { +- t.Fatalf("attach must never run against a dead runtime, got %d spawns", got) ++ t.Fatalf("attach must never run against a dead runtime, got %d attaches", got) + } + } + + // TestAttachmentRetriesProbeErrorsBeforeAttaching pins the hard rule that a + // failed liveness probe is NOT proof of death: a transient probe error must + // not flip the terminal to exited, and the attach proceeds once the probe + // recovers. + func TestAttachmentRetriesProbeErrorsBeforeAttaching(t *testing.T) { +- src := &fakeSource{aliveErr: io.ErrUnexpectedEOF} + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} +- a := newTestAttachment(src, sp.spawn, nil, nil) ++ src := &fakeSource{aliveErr: io.ErrUnexpectedEOF, spawner: sp} ++ a := newTestAttachment(src, nil, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.run(ctx) + + // While probes error the attachment must neither exit nor attach. + time.Sleep(50 * time.Millisecond) + if a.isExited() { + t.Fatal("probe error must not be treated as runtime death") + } + if got := sp.calls(); got != 0 { +- t.Fatalf("attach must wait for a successful probe, got %d spawns", got) ++ t.Fatalf("attach must wait for a successful probe, got %d attaches", got) + } + + // Probe recovers -> the attach goes through. + src.setAliveResult(true, nil) + eventually(t, 2*time.Second, func() bool { return sp.calls() == 1 }) + if a.isExited() { + t.Fatal("attachment exited despite a live runtime") + } + } + + func TestAttachmentReattachesWhileSessionAlive(t *testing.T) { +- src := &fakeSource{alive: true} // session still alive -> re-attach on drop + p1, p2 := newFakePTY(), newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{p1, p2}} +- a := newTestAttachment(src, sp.spawn, nil, nil) ++ src := &fakeSource{alive: true, spawner: sp} // session still alive -> re-attach on drop ++ a := newTestAttachment(src, nil, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.run(ctx) + + eventually(t, time.Second, func() bool { return sp.calls() >= 1 }) + if err := a.resize(24, 80); err != nil { + t.Fatalf("resize: %v", err) + } + p1.Close() // first attach drops +@@ -288,48 +287,50 @@ func TestAttachmentReattachesWhileSessionAlive(t *testing.T) { + } + return false + }) + + // Now the session is gone: the next drop must not re-attach. + src.setAlive(false) + p2.Close() + eventually(t, 2*time.Second, func() bool { return a.isExited() }) + } + +-func TestAttachmentFailsWhenAttachCommandErrors(t *testing.T) { ++// A persistent Attach error is treated like the old spawn-error path: it backs ++// off and retries up to the failure cap, then reports exited. (The old ++// AttachCommand-error path failed immediately; folding the argv build into ++// Attach means an attach failure now shares the spawn-failure retry policy, ++// which is the correct behavior for a transient dial/exec failure.) ++func TestAttachmentFailsWhenAttachErrors(t *testing.T) { + src := &fakeSource{alive: true, attachErr: io.ErrUnexpectedEOF} +- sp := &fakeSpawner{} + + exited := make(chan struct{}) +- a := newTestAttachment(src, sp.spawn, nil, func() { close(exited) }) ++ a := newTestAttachment(src, nil, func() { close(exited) }) ++ a.maxReattach = 2 // keep the retry budget small so the test is fast + + go a.run(context.Background()) + select { + case <-exited: +- case <-time.After(time.Second): +- t.Fatal("expected exit when attach command fails") +- } +- if sp.calls() != 0 { +- t.Fatalf("spawn should not run when attach command errors, got %d calls", sp.calls()) ++ case <-time.After(3 * time.Second): ++ t.Fatal("expected exit after attach errors exhaust the retry budget") + } + } + + // close() is a detach, not a pane death: it must stop the attach loop and kill +-// the client's PTY without firing onExit — the Zellij session is still alive ++// the client's PTY without firing onExit — the runtime session is still alive + // and an exited frame would wrongly flip the client UI to its terminal state. + func TestAttachmentCloseDoesNotFireExit(t *testing.T) { +- src := &fakeSource{alive: true} + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} ++ src := &fakeSource{alive: true, spawner: sp} + + exited := make(chan struct{}) +- a := newTestAttachment(src, sp.spawn, nil, func() { close(exited) }) ++ a := newTestAttachment(src, nil, func() { close(exited) }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + go func() { + a.run(ctx) + close(done) + }() + + eventually(t, time.Second, func() bool { return sp.calls() == 1 }) +@@ -339,21 +340,21 @@ func TestAttachmentCloseDoesNotFireExit(t *testing.T) { + case <-done: + case <-time.After(time.Second): + t.Fatal("run must return after close") + } + select { + case <-exited: + t.Fatal("close must not fire onExit") + default: + } + if got := sp.calls(); got != 1 { +- t.Fatalf("close must stop re-attaching, got %d spawns", got) ++ t.Fatalf("close must stop re-attaching, got %d attaches", got) + } + } + + type closeOrderPTY struct { + *fakePTY + ctx context.Context + before chan struct{} + after chan struct{} + once sync.Once + } +@@ -364,34 +365,33 @@ func (p *closeOrderPTY) Close() error { + case <-p.ctx.Done(): + close(p.after) + default: + close(p.before) + } + }) + return p.fakePTY.Close() + } + + func TestAttachmentCloseClosesPTYBeforeCancel(t *testing.T) { +- src := &fakeSource{alive: true} + beforeCancel := make(chan struct{}) + afterCancel := make(chan struct{}) + var spawnCtx context.Context +- spawn := func(ctx context.Context, _ []string, _ []string, _, _ uint16) (ptyProcess, error) { ++ src := &fakeSource{alive: true, attachFn: func(ctx context.Context, _, _ uint16) (ports.Stream, error) { + spawnCtx = ctx + return &closeOrderPTY{ + fakePTY: newFakePTY(), + ctx: ctx, + before: beforeCancel, + after: afterCancel, + }, nil +- } +- a := newTestAttachment(src, spawn, nil, nil) ++ }} ++ a := newTestAttachment(src, nil, nil) + + done := make(chan struct{}) + go func() { + a.run(context.Background()) + close(done) + }() + eventually(t, time.Second, func() bool { return currentPTY(a) != nil }) + + a.close() + select { +diff --git a/backend/internal/terminal/fakes_test.go b/backend/internal/terminal/fakes_test.go +index c0818f3..b196aec 100644 +--- a/backend/internal/terminal/fakes_test.go ++++ b/backend/internal/terminal/fakes_test.go +@@ -3,37 +3,43 @@ package terminal + import ( + "context" + "io" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + ) + +-// fakeSource is a scripted PTYSource. ++// fakeSource is a scripted terminal Source: Attach hands out fake Streams from ++// an embedded spawner (or a custom attachFn closure); IsAlive is scriptable. ++// attachErr makes Attach fail. + type fakeSource struct { +- argv []string ++ spawner *fakeSpawner ++ attachFn func(ctx context.Context, rows, cols uint16) (ports.Stream, error) + mu sync.Mutex + alive bool + aliveErr error + attachErr error + } + +-func (f *fakeSource) AttachCommand(ports.RuntimeHandle) ([]string, []string, error) { ++func (f *fakeSource) Attach(ctx context.Context, _ ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { + if f.attachErr != nil { +- return nil, nil, f.attachErr ++ return nil, f.attachErr + } +- if f.argv == nil { +- return []string{"zellij", "attach"}, nil, nil ++ if f.attachFn != nil { ++ return f.attachFn(ctx, rows, cols) + } +- return f.argv, nil, nil ++ if f.spawner == nil { ++ f.spawner = &fakeSpawner{} ++ } ++ return f.spawner.spawn(rows, cols) + } + + func (f *fakeSource) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.alive, f.aliveErr + } + + func (f *fakeSource) setAlive(v bool) { + f.mu.Lock() +@@ -41,22 +47,22 @@ func (f *fakeSource) setAlive(v bool) { + f.mu.Unlock() + } + + func (f *fakeSource) setAliveResult(v bool, err error) { + f.mu.Lock() + f.alive = v + f.aliveErr = err + f.mu.Unlock() + } + +-// fakePTY is a scripted ptyProcess: Read drains the out channel, Write records, +-// Resize records, and Close unblocks reads. ++// fakePTY is a scripted ports.Stream: Read drains the out channel, Write ++// records, Resize records, and Close unblocks reads. + type fakePTY struct { + out chan []byte + closed chan struct{} + once sync.Once + + mu sync.Mutex + written []byte + resizes [][2]uint16 + } + +@@ -103,30 +109,31 @@ func (p *fakePTY) writtenBytes() []byte { + } + + func (p *fakePTY) resizeCalls() [][2]uint16 { + p.mu.Lock() + defer p.mu.Unlock() + return append([][2]uint16(nil), p.resizes...) + } + + // fakeSpawner hands out pre-built fakePTYs in order; once exhausted it returns + // idle PTYs that block until closed (so a re-attach loop does not busy-spin). ++// It is the attach seam the fakeSource backs: each Attach call is one spawn. + type fakeSpawner struct { + mu sync.Mutex + ptys []*fakePTY + n int + err error + created []*fakePTY +- sizes [][2]uint16 // rows×cols passed to each spawn call, in order ++ sizes [][2]uint16 // rows×cols passed to each attach call, in order + } + +-func (f *fakeSpawner) spawn(_ context.Context, _ []string, _ []string, rows, cols uint16) (ptyProcess, error) { ++func (f *fakeSpawner) spawn(rows, cols uint16) (ports.Stream, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return nil, f.err + } + f.sizes = append(f.sizes, [2]uint16{rows, cols}) + var p *fakePTY + if f.n < len(f.ptys) { + p = f.ptys[f.n] + } else { +diff --git a/backend/internal/terminal/logger_test.go b/backend/internal/terminal/logger_test.go +index ba08a2d..1586e45 100644 +--- a/backend/internal/terminal/logger_test.go ++++ b/backend/internal/terminal/logger_test.go +@@ -1,19 +1,19 @@ + package terminal + + import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + ) + + func TestNilLoggerFallsBackToDefault(t *testing.T) { +- mgr := NewManager(&fakeSource{}, nil, nil, WithSpawn((&fakeSpawner{}).spawn)) ++ mgr := NewManager(&fakeSource{}, nil, nil) + defer mgr.Close() + if mgr.log == nil { + t.Fatal("manager logger is nil") + } +- a := newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, &fakeSource{}, (&fakeSpawner{}).spawn, nil, nil, nil, nil) ++ a := newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, &fakeSource{}, nil, nil, nil, nil) + if a.log == nil { + t.Fatal("attachment logger is nil") + } + } +diff --git a/backend/internal/terminal/manager.go b/backend/internal/terminal/manager.go +index 88ff36e..6dccfae 100644 +--- a/backend/internal/terminal/manager.go ++++ b/backend/internal/terminal/manager.go +@@ -27,61 +27,56 @@ type wsConn interface { + WriteJSON(ctx context.Context, v any) error + Ping(ctx context.Context) error + Close(reason string) error + } + + const ( + defaultHeartbeat = 15 * time.Second + defaultWriteBuffer = 1024 + ) + +-// Manager serves WebSocket clients, spawning one attach PTY per opened pane ++// Manager serves WebSocket clients, opening one attach Stream per opened pane + // per connection. There is no shared per-pane state to outlive a connection: +-// the Zellij server owns the session (screen, scrollback, modes), and every +-// fresh attach gets its full handshake + repaint. A client reconnect simply +-// attaches again. ++// the runtime owns the session (screen, scrollback, modes), and every fresh ++// attach gets its full handshake + repaint. A client reconnect simply attaches ++// again. + type Manager struct { +- src PTYSource ++ src Source + events EventSource +- spawn spawnFunc + log *slog.Logger + heartbeat time.Duration + + // ctx scopes every attachment's PTY lifetime; cancelled by Close. + ctx context.Context + cancel context.CancelFunc + + mu sync.Mutex + attachments map[*attachment]struct{} + closed bool + } + + // Option configures a Manager. + type Option func(*Manager) + +-// WithSpawn overrides the PTY spawner (tests inject a fake). +-func WithSpawn(fn spawnFunc) Option { return func(m *Manager) { m.spawn = fn } } +- + // WithHeartbeat overrides the ping interval. + func WithHeartbeat(d time.Duration) Option { return func(m *Manager) { m.heartbeat = d } } + +-// NewManager builds a Manager. src attaches PTYs; events feeds the session ++// NewManager builds a Manager. src opens attach Streams; events feeds the session + // channel (may be nil to disable it). A nil logger falls back to slog.Default. +-func NewManager(src PTYSource, events EventSource, log *slog.Logger, opts ...Option) *Manager { ++func NewManager(src Source, events EventSource, log *slog.Logger, opts ...Option) *Manager { + if log == nil { + log = slog.Default() + } + ctx, cancel := context.WithCancel(context.Background()) + m := &Manager{ + src: src, + events: events, +- spawn: defaultSpawn, + log: log, + heartbeat: defaultHeartbeat, + ctx: ctx, + cancel: cancel, + attachments: map[*attachment]struct{}{}, + } + for _, opt := range opts { + opt(m) + } + return m +@@ -198,39 +193,39 @@ func (c *connState) handleTerminal(msg clientMsg) { + } + case msgResize: + if a := c.lookup(msg.ID); a != nil { + _ = a.resize(msg.Rows, msg.Cols) + } + case msgClose: + c.closeTerminal(msg.ID) + } + } + +-// openTerminal spawns this connection's own attach PTY for the pane. rows/cols +-// are the client's grid from the open frame, applied as the PTY's initial size ++// openTerminal opens this connection's own attach Stream for the pane. rows/cols ++// are the client's grid from the open frame, applied as the Stream's initial size + // (a resize that raced ahead of the attach would otherwise be lost). + func (c *connState) openTerminal(id string, rows, cols uint16) { + if id == "" { + c.enqueue(serverMsg{Ch: chTerminal, Type: msgError, Error: "missing terminal id"}) + return + } + c.mu.Lock() + if _, ok := c.terms[id]; ok { + c.mu.Unlock() + return // already open on this conn; avoid a duplicate attach + } + c.mu.Unlock() + + // a is captured by onExit before assignment; safe because the attach loop — + // the only thing that fires onExit — starts after the registration below. + var a *attachment +- a = newAttachment(id, ports.RuntimeHandle{ID: id}, c.mgr.src, c.mgr.spawn, ++ a = newAttachment(id, ports.RuntimeHandle{ID: id}, c.mgr.src, + func() { + c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgOpened}) + }, + func(data []byte) { + c.enqueue(serverMsg{ + Ch: chTerminal, + ID: id, + Type: msgData, + Data: base64.StdEncoding.EncodeToString(data), + }) +diff --git a/backend/internal/terminal/manager_test.go b/backend/internal/terminal/manager_test.go +index 8c3e3ee..3bb2134 100644 +--- a/backend/internal/terminal/manager_test.go ++++ b/backend/internal/terminal/manager_test.go +@@ -2,20 +2,21 @@ package terminal + + import ( + "context" + "encoding/base64" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" ++ "github.com/aoagents/agent-orchestrator/backend/internal/ports" + ) + + // fakeConn is an in-memory wsConn driven by channels. + type fakeConn struct { + in chan clientMsg + out chan serverMsg + pings int32 + once sync.Once + closed chan struct{} + } +@@ -61,24 +62,24 @@ func recv(t *testing.T, c *fakeConn, ch, typ string, d time.Duration) serverMsg + if m.Ch == ch && m.Type == typ { + return m + } + case <-deadline: + t.Fatalf("did not receive %s/%s within %s", ch, typ, d) + } + } + } + + func TestServeOpenStreamsAndWritesTerminal(t *testing.T) { +- src := &fakeSource{alive: true} + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} +- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) ++ src := &fakeSource{alive: true, spawner: sp} ++ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) + defer mgr.Close() + + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + recv(t, conn, chTerminal, msgOpened, time.Second) + +@@ -93,30 +94,29 @@ func TestServeOpenStreamsAndWritesTerminal(t *testing.T) { + eventually(t, time.Second, func() bool { return string(pty.writtenBytes()) == "whoami\n" }) + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgResize, Rows: 30, Cols: 100} + eventually(t, time.Second, func() bool { + rs := pty.resizeCalls() + return len(rs) == 1 && rs[0] == [2]uint16{30, 100} + }) + } + + func TestServeBuffersInputUntilAttachReady(t *testing.T) { +- src := &fakeSource{alive: true} + pty := newFakePTY() + spawnStarted := make(chan struct{}) + releaseSpawn := make(chan struct{}) +- spawn := func(context.Context, []string, []string, uint16, uint16) (ptyProcess, error) { ++ src := &fakeSource{alive: true, attachFn: func(context.Context, uint16, uint16) (ports.Stream, error) { + close(spawnStarted) + <-releaseSpawn + return pty, nil +- } +- mgr := NewManager(src, nil, testLogger(), WithSpawn(spawn), WithHeartbeat(0)) ++ }} ++ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) + defer mgr.Close() + + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + select { + case <-spawnStarted: +@@ -141,53 +141,53 @@ func nextTerminal(t *testing.T, c *fakeConn) serverMsg { + t.Fatal("no frame within 1s") + return serverMsg{} + } + } + + // Opening a pane whose runtime is already dead must report exited without + // spawning an attach. The conn entry still has to clear so a later open for the + // same id on this connection is served instead of being silently dropped by the + // already-open guard. + func TestServeOpenDeadRuntimeReportsExitedAndAllowsReopen(t *testing.T) { +- src := &fakeSource{alive: false} + sp := &fakeSpawner{ptys: []*fakePTY{newFakePTY()}} +- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) ++ src := &fakeSource{alive: false, spawner: sp} ++ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) + defer mgr.Close() + + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + if m := nextTerminal(t, conn); m.Type != msgExited { + t.Fatalf("first frame = %q, want exited", m.Type) + } + if got := sp.calls(); got != 0 { +- t.Fatalf("attach must never run against a dead runtime, got %d spawns", got) ++ t.Fatalf("attach must never run against a dead runtime, got %d attaches", got) + } + + src.setAlive(true) + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + if m := nextTerminal(t, conn); m.Type != msgOpened { + t.Fatalf("re-open frame = %q, want opened (open was dropped, entry stuck)", m.Type) + } + } + + // A session that exits after being opened must clear its connection entry on + // exit, so a later open for the same id is served rather than dropped by the + // already-open guard. + func TestServeExitAfterOpenClearsEntryAllowingReopen(t *testing.T) { +- src := &fakeSource{alive: true} // alive for the first attach + p := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{p}} +- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) ++ src := &fakeSource{alive: true, spawner: sp} // alive for the first attach ++ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) + defer mgr.Close() + + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + recv(t, conn, chTerminal, msgOpened, time.Second) + +@@ -202,61 +202,61 @@ func TestServeExitAfterOpenClearsEntryAllowingReopen(t *testing.T) { + + // An attachment that exits the moment it is opened (dead runtime) fires onExit + // from its run goroutine, racing the reopen that follows the exited frame. The + // identity-guarded delete in onExit must never evict a successor attachment + // registered under the same id: every reopen must be served (exited again for a + // still-dead runtime), never silently dropped by the already-open guard. + // Stressed across many iterations + // to shake the exit/reopen interleavings out. + func TestServeReopenAfterImmediateExitNeverStuck(t *testing.T) { + for i := 0; i < 400; i++ { +- src := &fakeSource{} +- src.setAlive(false) // dead runtime -> the open exits without attaching + sp := &fakeSpawner{} +- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) ++ src := &fakeSource{spawner: sp} ++ src.setAlive(false) // dead runtime -> the open exits without attaching ++ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) + + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + + recv(t, conn, chTerminal, msgExited, time.Second) + + // The reopen must be served even while the first open's exit teardown is + // still in flight. + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + recv(t, conn, chTerminal, msgExited, time.Second) + + cancel() + mgr.Close() + } + } + + func TestServeRejectsOpenWithoutID(t *testing.T) { +- mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) ++ mgr := NewManager(&fakeSource{}, nil, testLogger(), WithHeartbeat(0)) + defer mgr.Close() + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chTerminal, Type: msgOpen} + msg := recv(t, conn, chTerminal, msgError, time.Second) + if msg.Error == "" { + t.Fatal("expected an error message for open without id") + } + } + + func TestServeForwardsSessionChannelFromCDC(t *testing.T) { + bc := cdc.NewBroadcaster() +- mgr := NewManager(&fakeSource{}, bc, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) ++ mgr := NewManager(&fakeSource{}, bc, testLogger(), WithHeartbeat(0)) + defer mgr.Close() + + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chSubscribe, Type: msgSubscribe} + // Give the subscription time to register before publishing. + eventually(t, time.Second, func() bool { +@@ -264,84 +264,84 @@ func TestServeForwardsSessionChannelFromCDC(t *testing.T) { + select { + case m := <-conn.out: + return m.Ch == chSessions && m.Session != nil && m.Session.Seq == 9 + default: + return false + } + }) + } + + func TestServeSystemPingGetsPong(t *testing.T) { +- mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) ++ mgr := NewManager(&fakeSource{}, nil, testLogger(), WithHeartbeat(0)) + defer mgr.Close() + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chSystem, Type: msgPing} + recv(t, conn, chSystem, msgPong, time.Second) + } + + func TestServeHeartbeatPings(t *testing.T) { +- mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(10*time.Millisecond)) ++ mgr := NewManager(&fakeSource{}, nil, testLogger(), WithHeartbeat(10*time.Millisecond)) + defer mgr.Close() + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + eventually(t, time.Second, func() bool { return atomic.LoadInt32(&conn.pings) >= 2 }) + } + + func TestServeClosesConnOnReadEnd(t *testing.T) { +- mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) ++ mgr := NewManager(&fakeSource{}, nil, testLogger(), WithHeartbeat(0)) + defer mgr.Close() + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + go mgr.Serve(ctx, conn) + + cancel() // client/server context ends + select { + case <-conn.closed: + case <-time.After(time.Second): + t.Fatal("Serve must close the conn when the context is cancelled") + } + } + + // Each connection opening the same pane gets its OWN attach PTY — that is the +-// per-client model: zellij replays its init handshake + full repaint to every ++// per-client model: the runtime replays its init handshake + full repaint to every + // fresh attach, so no client depends on bytes another client consumed. Output + // pushed to one client's PTY must reach only that client, and closing one + // client's terminal must not touch the other's PTY. + func TestServePerClientAttachIsolation(t *testing.T) { +- src := &fakeSource{alive: true} + p1, p2 := newFakePTY(), newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{p1, p2}} +- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) ++ src := &fakeSource{alive: true, spawner: sp} ++ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) + defer mgr.Close() + + connA, connB := newFakeConn(), newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, connA) + go mgr.Serve(ctx, connB) + + connA.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + recv(t, connA, chTerminal, msgOpened, time.Second) + eventually(t, time.Second, func() bool { return sp.calls() == 1 }) + + connB.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + recv(t, connB, chTerminal, msgOpened, time.Second) + eventually(t, time.Second, func() bool { return sp.calls() == 2 }) + +- // Zellij fans output out per attach; here each fake PTY stands in for one ++ // The runtime fans output out per attach; here each fake PTY stands in for one + // attach process, so its bytes must reach exactly its own connection. + p1.push([]byte("for-A")) + data := recv(t, connA, chTerminal, msgData, time.Second) + got, _ := base64.StdEncoding.DecodeString(data.Data) + if string(got) != "for-A" { + t.Fatalf("conn A data = %q", got) + } + select { + case m := <-connB.out: + t.Fatalf("conn B received %s/%s for conn A's PTY output", m.Ch, m.Type) +@@ -361,46 +361,46 @@ func TestServePerClientAttachIsolation(t *testing.T) { + select { + case <-p2.closed: + t.Fatal("closing conn A's terminal must not close conn B's PTY") + default: + } + } + + // The open frame carries the client's grid; the PTY must start at that size + // rather than the kernel default, even though the attach is asynchronous. + func TestServeOpenAppliesInitialSize(t *testing.T) { +- src := &fakeSource{alive: true} + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} +- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) ++ src := &fakeSource{alive: true, spawner: sp} ++ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) + defer mgr.Close() + + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen, Rows: 40, Cols: 120} + recv(t, conn, chTerminal, msgOpened, time.Second) + eventually(t, time.Second, func() bool { + rs := pty.resizeCalls() + return len(rs) == 1 && rs[0] == [2]uint16{40, 120} + }) + } + + // Manager.Close must kill every live attach PTY: a PTY left open keeps its + // attach process running and deadlocks daemon shutdown. + func TestManagerCloseKillsLiveAttachments(t *testing.T) { +- src := &fakeSource{alive: true} + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} +- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) ++ src := &fakeSource{alive: true, spawner: sp} ++ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) + + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + recv(t, conn, chTerminal, msgOpened, time.Second) + eventually(t, time.Second, func() bool { return sp.calls() == 1 }) + +--- Changes --- + +backend/internal/adapters/runtime/conpty/attach.go + @@ -0,0 +1,116 @@ + +// attach.go - conpty Attach: a loopback Stream over the B3 pty-host. Unlike + +// tmux/zellij, conpty does not spawn an attach CLI; it dials the session's + +// loopback host and speaks the B1 framing protocol directly. The host replays + +// the scrollback Snapshot as the first MsgTerminalData on connect, so a fresh + +// Read naturally yields the repaint first. + +package conpty + + + +import ( + + "context" + + "encoding/json" + + "fmt" + + "io" + + "sync" + + + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + +) + + + +var _ ports.Attacher = (*Runtime)(nil) + + + +// Attach opens a fresh attach Stream for the session by dialing its loopback + +// pty-host. rows/cols size the host's PTY from birth when known (a MsgResize is + +// sent right after connect). ctx cancellation closes the Stream. + +func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { + + sess := r.resolve(handle.ID) + + if sess == nil { + + return nil, fmt.Errorf("conpty: session %q not found", handle.ID) + + } + + conn, err := dialHost(sess.addr, dialTimeout) + + if err != nil { + + return nil, fmt.Errorf("conpty: dial host for %q: %w", handle.ID, err) + + } + + + + pr, pw := io.Pipe() + + s := &loopbackStream{conn: conn, pr: pr, pw: pw} + + + + // Pump host frames: MsgTerminalData payloads go into the pipe that Read + + // drains. The first such frame is the scrollback snapshot, so the replay + + // arrives before any live output. + + go s.pump() + + + + // ctx cancellation must terminate the stream (mirrors the unix/windows + + // spawn paths closing the PTY on ctx.Done). + + go func() { + + <-ctx.Done() + + _ = s.Close() + + }() + + + + if rows > 0 && cols > 0 { + + if err := s.Resize(rows, cols); err != nil { + + _ = s.Close() + + return nil, err + + } + + } + + return s, nil + +} + + + +// loopbackStream is a ports.Stream backed by a single loopback connection to the + +// pty-host. The pump goroutine reframes host output into an io.Pipe so Read + +// presents a plain byte stream; Write/Resize encode client frames onto the conn. + +type loopbackStream struct { + + conn io.ReadWriteCloser + + pr *io.PipeReader + + pw *io.PipeWriter + + + + closeOnce sync.Once + +} + + + +// pump reads framed host messages and writes MsgTerminalData payloads into the + +// pipe. It closes the pipe when the connection ends so Read returns EOF. + +func (s *loopbackStream) pump() { + + parser := NewMessageParser(func(msgType byte, payload []byte) { + + if msgType == MsgTerminalData { + + // Write blocks until Read drains, preserving back-pressure and order. + + _, _ = s.pw.Write(payload) + + } + + }) + + buf := make([]byte, 4096) + + for { + + n, err := s.conn.Read(buf) + + if n > 0 { + + parser.Feed(buf[:n]) + + } + + if err != nil { + + _ = s.pw.CloseWithError(err) + + return + + } + + } + +} + + + +func (s *loopbackStream) Read(p []byte) (int, error) { return s.pr.Read(p) } + + + +func (s *loopbackStream) Write(p []byte) (int, error) { + + if _, err := s.conn.Write(EncodeMessage(MsgTerminalInput, p)); err != nil { + + return 0, err + + } + + return len(p), nil + +} + + + +func (s *loopbackStream) Resize(rows, cols uint16) error { + + payload, _ := json.Marshal(ResizePayload{Cols: int(cols), Rows: int(rows)}) + ... (16 lines truncated) + +116 -0 + +backend/internal/adapters/runtime/conpty/attach_test.go + @@ -0,0 +1,151 @@ + +package conpty + + + +import ( + + "bytes" + + "context" + + "io" + + "testing" + + "time" + + + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + +) + + + +// runtimeForFixture wires a conpty Runtime to a running serveFixture by stuffing + +// the fixture's loopback addr into the session map under the given id, so Attach + +// resolves it without a real Windows spawn. + +func runtimeForFixture(id string, f *serveFixture) *Runtime { + + r := New(Options{}) + + r.mu.Lock() + + r.sessions[id] = &hostSession{addr: f.addr, pid: f.pty.PID()} + + r.mu.Unlock() + + return r + +} + + + +func readUntil(t *testing.T, s io.Reader, want string, timeout time.Duration) string { + + t.Helper() + + type res struct { + + out string + + } + + done := make(chan res, 1) + + go func() { + + var buf []byte + + tmp := make([]byte, 4096) + + for { + + n, err := s.Read(tmp) + + if n > 0 { + + buf = append(buf, tmp[:n]...) + + if bytes.Contains(buf, []byte(want)) { + + done <- res{string(buf)} + + return + + } + + } + + if err != nil { + + done <- res{string(buf)} + + return + + } + + } + + }() + + select { + + case r := <-done: + + return r.out + + case <-time.After(timeout): + + t.Fatalf("timed out reading for %q", want) + + return "" + + } + +} + + + +// TestAttachReplaysScrollback: the host sends the ring snapshot as the first + +// MsgTerminalData on connect, so a fresh Read on the Stream yields the replay. + +func TestAttachReplaysScrollback(t *testing.T) { + + f := startServe(t, 300) + + defer f.cancel() + + f.ring.Append([]byte("scrollback-line\n")) + + + + r := runtimeForFixture("sess", f) + + s, err := r.Attach(context.Background(), nameHandle("sess"), 0, 0) + + if err != nil { + + t.Fatalf("Attach: %v", err) + + } + + defer s.Close() + + + + out := readUntil(t, s, "scrollback-line", 2*time.Second) + + if !bytes.Contains([]byte(out), []byte("scrollback-line")) { + + t.Fatalf("scrollback not replayed on Read; got %q", out) + + } + +} + + + +// TestAttachWriteReachesPTY: Write on the Stream sends MsgTerminalInput, which + +// the host forwards to the fakePTY's input. + +func TestAttachWriteReachesPTY(t *testing.T) { + + f := startServe(t, 301) + + defer f.cancel() + + + + r := runtimeForFixture("sess", f) + + s, err := r.Attach(context.Background(), nameHandle("sess"), 0, 0) + + if err != nil { + + t.Fatalf("Attach: %v", err) + + } + + defer s.Close() + + + + keystrokes := []byte("ls -la\r") + + if _, err := s.Write(keystrokes); err != nil { + + t.Fatalf("Write: %v", err) + + } + + buf := make([]byte, len(keystrokes)) + + if _, err := io.ReadFull(f.pty.inR, buf); err != nil { + + t.Fatalf("read pty input: %v", err) + + } + + if string(buf) != string(keystrokes) { + + t.Fatalf("pty input = %q, want %q", buf, keystrokes) + + } + ... (51 lines truncated) + +151 -0 + +backend/internal/adapters/runtime/conpty/runtime.go + @@ -1,27 +1,27 @@ + -// runtime.go - conpty Runtime adapter. Implements ports.Runtime (minus Attach, + -// which comes in B5). Drives sessions via the B3 pty-host over loopback TCP, + -// using the B1 protocol and the B2 registry for restart recovery. + +// runtime.go - conpty Runtime adapter. Implements ports.Runtime and + +// ports.Attacher (see attach.go). Drives sessions via the B3 pty-host over + +// loopback TCP, using the B1 protocol and the B2 registry for restart recovery. + package conpty + + import ( + "context" + "fmt" + "regexp" + "sync" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + ) + + -// Ensure Runtime satisfies the port at compile time. Attach is added in B5. + +// Ensure Runtime satisfies the port at compile time (Attach in attach.go). + var _ ports.Runtime = (*Runtime)(nil) + + // validSessionID matches agent-orchestrator's assertValidSessionId. + var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + + // hostSession is the in-memory state for a live pty-host connection. + type hostSession struct { + addr string + pid int + } + +4 -4 + +backend/internal/adapters/runtime/ptyexec/spawn_unix.go + @@ -1,37 +1,42 @@ + -package terminal + +// Package ptyexec spawns a local PTY around an attach CLI (tmux/zellij) and + +// exposes it as a ports.Stream. It is the shared spawn the terminal layer used + +// to own directly; extracting it lets each runtime adapter back its Attach with + +// the same creack/pty (unix) or go-pty ConPTY (windows) plumbing. + +package ptyexec + + import ( + "context" + "errors" + "os" + "os/exec" + "sync" + "syscall" + "time" + + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/creack/pty" + ) + + -// defaultSpawn starts argv on a real PTY via creack/pty, sized rows×cols from + -// birth when a size is known: `zellij attach` reads the tty size once at + -// startup, and a post-spawn TIOCSWINSZ depends on SIGWINCH delivery that can + -// race the client installing its handler — StartWithSize makes the first read + -// correct by construction. env, when non-nil, replaces the inherited + -// environment (mirrors exec.Cmd.Env semantics). ctx cancellation closes the PTY + -// through the same graceful detach path as an explicit client close. Windows uses + -// a stub (see pty_windows.go) until a ConPTY path is added. + -func defaultSpawn(ctx context.Context, argv, env []string, rows, cols uint16) (ptyProcess, error) { + +// Spawn starts argv on a real PTY via creack/pty, sized rows×cols from birth + +// when a size is known: an attach client reads the tty size once at startup, and + +// a post-spawn TIOCSWINSZ depends on SIGWINCH delivery that can race the client + +// installing its handler — StartWithSize makes the first read correct by + +// construction. env, when non-nil, replaces the inherited environment (mirrors + +// exec.Cmd.Env semantics). ctx cancellation closes the PTY through the same + +// graceful detach path as an explicit client close. Windows uses a ConPTY path + +// (see spawn_windows.go). + +func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) { + if len(argv) == 0 { + - return nil, errors.New("terminal: empty attach command") + + return nil, errors.New("ptyexec: empty attach command") + } + if err := ctx.Err(); err != nil { + return nil, err + } + cmd := exec.Command(argv[0], argv[1:]...) + if env != nil { + cmd.Env = env + } + var f *os.File + var err error + @@ -58,42 +63,42 @@ type creackPTY struct { + - // reach a zellij client that missed or lost the original signal — the + + // reach an attach client that missed or lost the original signal — the + // session would stay laid out for a stale size, with no repaint until the + // next real change (the frontend re-sends its grid after each resize burst + // for exactly this self-heal; see useTerminalSession). The client re-reads + // the tty and re-reports to its server; when already in sync it's a no-op. + if p.cmd.Process != nil { + _ = p.cmd.Process.Signal(syscall.SIGWINCH) + } + return err + } + + // detachGrace is how long Close waits for a SIGTERM'd attach process to exit + -// on its own before falling back to SIGKILL. A zellij client that is being + +// on its own before falling back to SIGKILL. An attach client that is being + // drained detaches in ~50ms; the grace only runs out for a wedged process. + const detachGrace = 250 * time.Millisecond + + // Close stops the attach process and releases the PTY. + // + -// SIGTERM first, SIGKILL as fallback: a SIGTERM'd `zellij attach` deregisters + -// itself from the zellij server before exiting, while a SIGKILL'd one leaves + +// SIGTERM first, SIGKILL as fallback: a SIGTERM'd attach client deregisters + +// itself from its mux server before exiting, while a SIGKILL'd one leaves + // deregistration to the server noticing the dead socket. A dead-but-registered + -// client pins the session's size (zellij sizes a session to its smallest + +// client pins the session's size (a mux sizes a session to its smallest + // client), so the next attach renders for the ghost's grid — the "terminal + // doesn't repaint to the new size" desync. The master stays open through the + // grace so the run loop's copyOut keeps draining the client's shutdown output + // (a blocked tty write would stall the graceful exit past the grace). + // + // It is idempotent: both the attachment run loop (after copyOut returns) and + // attachment.close (via closeTerminal, conn cleanup, or Manager.Close) call + // Close on the same PTY, and cmd.Wait must run exactly once. A second + // concurrent Wait on the same process blocks forever, deadlocking daemon + // shutdown when a terminal is still attached. + +21 -16 + +backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go + @@ -1,53 +1,53 @@ + -package terminal + +package ptyexec + + import ( + "context" + "strings" + "testing" + "time" + ) + + // TestCreackPTYCloseIsIdempotent guards the shutdown deadlock: the session run + // loop and session.close both call Close on the same PTY, so cmd.Wait must run + // exactly once. Without the sync.Once a second Wait blocks forever, so this test + // would hang (caught by the watchdog) rather than fail. + func TestCreackPTYCloseIsIdempotent(t *testing.T) { + - p, err := defaultSpawn(context.Background(), []string{"/bin/sh", "-c", "sleep 30"}, nil, 0, 0) + + p, err := Spawn(context.Background(), []string{"/bin/sh", "-c", "sleep 30"}, nil, 0, 0) + if err != nil { + t.Fatalf("spawn: %v", err) + } + + done := make(chan struct{}) + go func() { + _ = p.Close() + _ = p.Close() // second close must not block on a second cmd.Wait + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("creackPTY.Close did not return: double Close deadlocked on cmd.Wait") + } + } + + // TestCreackPTYResizeSignalsOnIdenticalSize guards the resize self-heal: the + // kernel only raises SIGWINCH when TIOCSWINSZ actually changes the size, so a + -// re-asserted (identical) grid relies on Resize's explicit signal. A zellij + +// re-asserted (identical) grid relies on Resize's explicit signal. An attach + // client that lost the original update would otherwise keep its server laid + // out for a stale size forever — the "terminal doesn't repaint after resizing + // the pane" desync. + func TestCreackPTYResizeSignalsOnIdenticalSize(t *testing.T) { + - p, err := defaultSpawn(context.Background(), + + p, err := Spawn(context.Background(), + []string{"/bin/sh", "-c", `trap 'echo WINCHED' WINCH; while :; do sleep 0.05; done`}, nil, 0, 0) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Close() + + // Give the shell a beat to install the trap, then resize twice to the SAME + // size. The first call changes the size (fresh PTYs start at 0x0) and the + // second is identical — only the explicit signal can deliver it. + time.Sleep(200 * time.Millisecond) + @@ -73,23 +73,23 @@ func TestCreackPTYResizeSignalsOnIdenticalSize(t *testing.T) { + -// zellij session at the previous client's size). + +// session at the previous client's size). + func TestCreackPTYSpawnsAtRequestedSize(t *testing.T) { + - p, err := defaultSpawn(context.Background(), []string{"/bin/sh", "-c", "stty size"}, nil, 40, 140) + + p, err := Spawn(context.Background(), []string{"/bin/sh", "-c", "stty size"}, nil, 40, 140) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Close() + + deadline := time.Now().Add(5 * time.Second) + var out strings.Builder + buf := make([]byte, 4096) + for time.Now().Before(deadline) { + n, readErr := p.Read(buf) + @@ -100,40 +100,40 @@ func TestCreackPTYSpawnsAtRequestedSize(t *testing.T) { + -// chance to exit on SIGTERM (a zellij client deregisters from its server on + +// chance to exit on SIGTERM (an attach client deregisters from its server on + // SIGTERM; a straight SIGKILL leaves a ghost client that pins the session's + // size), and must still return promptly for a process that ignores SIGTERM. + func TestCreackPTYCloseTermsBeforeKill(t *testing.T) { + t.Run("cooperative process exits within the grace", func(t *testing.T) { + - p, err := defaultSpawn(context.Background(), + + p, err := Spawn(context.Background(), + []string{"/bin/sh", "-c", `trap 'exit 0' TERM; while :; do sleep 0.05; done`}, nil, 0, 0) + if err != nil { + t.Fatalf("spawn: %v", err) + } + time.Sleep(200 * time.Millisecond) // let the trap install + start := time.Now() + _ = p.Close() + if elapsed := time.Since(start); elapsed >= detachGrace { + t.Fatalf("Close took %v: SIGTERM path did not let a cooperative process exit before the kill grace", elapsed) + } + }) + + t.Run("TERM-ignoring process is killed after the grace", func(t *testing.T) { + - p, err := defaultSpawn(context.Background(), + + p, err := Spawn(context.Background(), + []string{"/bin/sh", "-c", `trap '' TERM; while :; do sleep 0.05; done`}, nil, 0, 0) + if err != nil { + t.Fatalf("spawn: %v", err) + } + time.Sleep(200 * time.Millisecond) + done := make(chan struct{}) + go func() { + _ = p.Close() + close(done) + }() + +9 -9 + +backend/internal/adapters/runtime/ptyexec/spawn_windows.go + @@ -1,38 +1,39 @@ + -package terminal + +package ptyexec + + import ( + "context" + "errors" + "sync" + "time" + + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + winpty "github.com/aymanbagabas/go-pty" + ) + + // detachGrace mirrors the Unix value: how long Close waits for the attach + // process to exit on its own (closing the ConPTY surfaces as EOF on the + // child's stdin) before falling back to Kill. + const detachGrace = 250 * time.Millisecond + + -// defaultSpawn starts argv on a Windows ConPTY and exposes the console pipes + -// through the same ptyProcess interface used by the Unix creack/pty path. + -// go-pty creates the pseudo-console at 80x25 internally, so we only Resize + -// when the caller actually has a grid (mirroring StartWithSize on Unix). + -// env, when non-nil, replaces the inherited environment via Win32's native + -// CreateProcess env block (mirrors exec.Cmd.Env semantics) — this is how a + -// per-session ZELLIJ_SOCKET_DIR reaches the zellij attach client. + -func defaultSpawn(ctx context.Context, argv []string, env []string, rows, cols uint16) (ptyProcess, error) { + +// Spawn starts argv on a Windows ConPTY and exposes the console pipes through + +// the same ports.Stream interface used by the Unix creack/pty path. go-pty + +// creates the pseudo-console at 80x25 internally, so we only Resize when the + +// caller actually has a grid (mirroring StartWithSize on Unix). env, when + +// non-nil, replaces the inherited environment via Win32's native CreateProcess + +// env block (mirrors exec.Cmd.Env semantics) — this is how a per-session + +// ZELLIJ_SOCKET_DIR reaches the zellij attach client. + +func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) { + if len(argv) == 0 { + - return nil, errors.New("terminal: empty attach command") + + return nil, errors.New("ptyexec: empty attach command") + } + pty, err := winpty.New() + if err != nil { + return nil, err + } + if rows > 0 && cols > 0 { + if err := pty.Resize(int(cols), int(rows)); err != nil { + _ = pty.Close() + return nil, err + } + +11 -10 + +backend/internal/adapters/runtime/ptyexec/spawn_windows_test.go + @@ -1,68 +1,70 @@ + -package terminal + +package ptyexec + + import ( + "bytes" + "context" + +... (more changes truncated) + +1 -1 +[full diff: rtk git diff --no-compact] diff --git a/backend/internal/adapters/runtime/ptyexec/spawn_unix.go b/backend/internal/adapters/runtime/ptyexec/spawn_unix.go index e7d6d2c5..9dc6a2f9 100644 --- a/backend/internal/adapters/runtime/ptyexec/spawn_unix.go +++ b/backend/internal/adapters/runtime/ptyexec/spawn_unix.go @@ -22,7 +22,7 @@ import ( // Spawn starts argv on a real PTY via creack/pty, sized rows×cols from birth // when a size is known: an attach client reads the tty size once at startup, and // a post-spawn TIOCSWINSZ depends on SIGWINCH delivery that can race the client -// installing its handler — StartWithSize makes the first read correct by +// installing its handler; StartWithSize makes the first read correct by // construction. env, when non-nil, replaces the inherited environment (mirrors // exec.Cmd.Env semantics). ctx cancellation closes the PTY through the same // graceful detach path as an explicit client close. Windows uses a ConPTY path @@ -70,7 +70,7 @@ func (p *creackPTY) Resize(rows, cols uint16) error { err := pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols}) // Always follow with an explicit SIGWINCH: the kernel only raises one when // the size actually changed, so a re-asserted (identical) grid would never - // reach an attach client that missed or lost the original signal — the + // reach an attach client that missed or lost the original signal; the // session would stay laid out for a stale size, with no repaint until the // next real change (the frontend re-sends its grid after each resize burst // for exactly this self-heal; see useTerminalSession). The client re-reads @@ -92,7 +92,7 @@ const detachGrace = 250 * time.Millisecond // itself from its mux server before exiting, while a SIGKILL'd one leaves // deregistration to the server noticing the dead socket. A dead-but-registered // client pins the session's size (a mux sizes a session to its smallest -// client), so the next attach renders for the ghost's grid — the "terminal +// client), so the next attach renders for the ghost's grid; the "terminal // doesn't repaint to the new size" desync. The master stays open through the // grace so the run loop's copyOut keeps draining the client's shutdown output // (a blocked tty write would stall the graceful exit past the grace). diff --git a/backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go b/backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go index a0afefa9..5435312d 100644 --- a/backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go +++ b/backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go @@ -37,7 +37,7 @@ func TestCreackPTYCloseIsIdempotent(t *testing.T) { // kernel only raises SIGWINCH when TIOCSWINSZ actually changes the size, so a // re-asserted (identical) grid relies on Resize's explicit signal. An attach // client that lost the original update would otherwise keep its server laid -// out for a stale size forever — the "terminal doesn't repaint after resizing +// out for a stale size forever; the "terminal doesn't repaint after resizing // the pane" desync. func TestCreackPTYResizeSignalsOnIdenticalSize(t *testing.T) { p, err := Spawn(context.Background(), @@ -49,7 +49,7 @@ func TestCreackPTYResizeSignalsOnIdenticalSize(t *testing.T) { // Give the shell a beat to install the trap, then resize twice to the SAME // size. The first call changes the size (fresh PTYs start at 0x0) and the - // second is identical — only the explicit signal can deliver it. + // second is identical; only the explicit signal can deliver it. time.Sleep(200 * time.Millisecond) if err := p.Resize(24, 80); err != nil { t.Fatalf("resize 1: %v", err) @@ -78,7 +78,7 @@ func TestCreackPTYResizeSignalsOnIdenticalSize(t *testing.T) { } // TestCreackPTYSpawnsAtRequestedSize: the child must see the requested grid on -// its very first TIOCGWINSZ, with no SIGWINCH involved — sizing after exec +// its very first TIOCGWINSZ, with no SIGWINCH involved; sizing after exec // races the client installing its WINCH handler (a missed signal strands the // session at the previous client's size). func TestCreackPTYSpawnsAtRequestedSize(t *testing.T) { diff --git a/backend/internal/adapters/runtime/ptyexec/spawn_windows.go b/backend/internal/adapters/runtime/ptyexec/spawn_windows.go index 8f19f55e..b0b019b6 100644 --- a/backend/internal/adapters/runtime/ptyexec/spawn_windows.go +++ b/backend/internal/adapters/runtime/ptyexec/spawn_windows.go @@ -22,7 +22,7 @@ const detachGrace = 250 * time.Millisecond // creates the pseudo-console at 80x25 internally, so we only Resize when the // caller actually has a grid (mirroring StartWithSize on Unix). env, when // non-nil, replaces the inherited environment via Win32's native CreateProcess -// env block (mirrors exec.Cmd.Env semantics) — this is how a per-session +// env block (mirrors exec.Cmd.Env semantics); this is how a per-session // ZELLIJ_SOCKET_DIR reaches the zellij attach client. func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) { if len(argv) == 0 { From b9ef1f5e28865396bc645e05e1f439c4608aa6a3 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 24 Jun 2026 00:33:53 +0530 Subject: [PATCH 18/60] chore(sdd): B6 brief + B5 ledger Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/progress.md | 2 +- .superpowers/sdd/task-b6-brief.md | 105 ++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 .superpowers/sdd/task-b6-brief.md diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index cbbb5f65..c45cb582 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -29,7 +29,7 @@ Tasks: - B2: windows pty-host registry — COMPLETE (..6552e26, 10 tests -race clean, win+linux+darwin build) - B3: pty-host serve engine (loopback TCP, NOT named pipe; ConPTY behind interface seam) — COMPLETE (41ce417..6cd6e2a, 35 tests -race clean incl. ordering regression test, review APPROVED; loopback decision documented; Windows go-pty file compile-checked only) - B4: conpty runtime adapter — COMPLETE (be06609..0bc26e5, 49 tests -race, IsAlive dead-vs-transient fixed, registry-recovery path; Windows spawn file compile-checked only) -- B5: terminal Attacher/Stream interface change + tmux/zellij/conpty Attach impls — Darwin-testable — NOT STARTED +- B5: terminal Attach/Stream interface change + tmux/zellij/conpty Attach — COMPLETE (8d757ed..HEAD, 1638 tests -race across 78 pkgs, all 3 GOOS, real tmux+zellij attach integration pass, review APPROVED; conpty loopbackStream stress-tested clean) - B6: select conpty on Windows + delete zellij + doctor/spawn + register pty-host subcommand — NOT STARTED Faithful-port references (agent-orchestrator): diff --git a/.superpowers/sdd/task-b6-brief.md b/.superpowers/sdd/task-b6-brief.md new file mode 100644 index 00000000..905ebcd4 --- /dev/null +++ b/.superpowers/sdd/task-b6-brief.md @@ -0,0 +1,105 @@ +# Task B6: select conpty on Windows, register pty-host subcommand, delete zellij + +## Goal +Final wiring of the migration: make Windows use the conpty runtime, register the +`ao pty-host` subcommand that conpty spawns, DELETE the zellij package, and clean up +the remaining zellij references (doctor, spawn hint, wiring test, the terminal attach +integration test). After this, the end state is: tmux on Darwin/Linux, conpty on +Windows, no zellij anywhere. Keep all three GOOS builds green and the full suite +passing on Darwin. + +Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, +module `backend/`, branch `migrate-zellij-to-tmux-conpty`. Module prefix +`github.com/aoagents/agent-orchestrator/backend`. + +## Current state (verified) +- `runtimeselect.New(log)` returns tmux on non-Windows, zellij on Windows. The union + interface already embeds ports.Attacher (Attach). Both tmux and conpty packages + build on all platforms (conpty's real ConPTY spawn is windows-tagged; a non-windows + stub errors). conpty.Runtime implements Create/Destroy/IsAlive/SendMessage/GetOutput + AND Attach (B5), so it satisfies the union. +- conpty's `defaultSpawnHost` (windows-tagged) spawns ` pty-host + ` and reads `READY: `. The + `conpty.RunHost(args []string, stdout io.Writer) int` entrypoint exists but is NOT + yet registered as a CLI subcommand. +- zellij is referenced outside its package by: `cli/spawn.go` (attach hint), + `cli/doctor.go` (version check), `runtimeselect/runtimeselect.go` (windows branch), + `daemon/wiring_test.go` (DefaultSocketDir test + zellij.New), `lifecycle_wiring.go` + (verify: likely a stale comment/import), and `terminal/attachment_integration_test.go` + (drives a REAL zellij pane). +- `internal/agentlaunch` is used by `cli/launch.go` (the `ao launch` trampoline) AND + zellij. After deleting zellij, agentlaunch is still used by cli/launch.go, so KEEP + agentlaunch. + +## Step 1 — switch runtimeselect to conpty on Windows +In `internal/adapters/runtime/runtimeselect/runtimeselect.go`: +- Windows branch returns `conpty.New(conpty.Options{})` (use its real default options; + check conpty.New's signature). Remove the zellij import, the DefaultSocketDir + + MkdirAll + warn block (conpty needs no socket dir). The `log` param may become + unused; if so, keep the signature (callers pass it) but use `_ = log` or drop the + param and update the one caller in daemon.go (prefer keeping the signature stable; + use `_ = log` with a short comment, or rename to `_`). +- Replace the `var _ Runtime = (*zellij.Runtime)(nil)` assertion with + `var _ Runtime = (*conpty.Runtime)(nil)`. Keep the tmux assertion. +- Update the package doc comment to "tmux on Darwin/Linux, conpty (ConPTY) on Windows". + +## Step 2 — register the `ao pty-host` subcommand +Find how existing hidden/internal subcommands are registered (look at +`internal/cli/launch.go` for the `ao launch` trampoline command and how +`internal/cli/root.go` wires subcommands). Add a `pty-host` command (mark it Hidden +like launch if launch is hidden) whose RunE calls +`conpty.RunHost(args, os.Stdout)` and exits with that code (use +`os.Exit(code)` or return an error that maps to the code; match how `launch` does +it). It takes raw positional args (sessionID, cwd, shellCmd, shellArg...). Disable +cobra flag parsing for these positionals if needed (e.g. `DisableFlagParsing: true` +or `Args: cobra.ArbitraryArgs`) so agent shell args with leading dashes are not +eaten. Confirm the arg order matches what conpty's `defaultSpawnHost` passes and what +`RunHost` expects (read both). + +## Step 3 — delete the zellij package +- `git rm -r internal/adapters/runtime/zellij`. +- Fix every now-broken reference: + - `cli/doctor.go`: remove the zellij version check (the `zellij.CheckVersionOutput`/ + `RequiredVersion` block). On Windows there is no external terminal-multiplexer tool + to check (ConPTY is built into the daemon binary). Either drop the Windows + terminal-tool check entirely, or emit a trivial pass like "ConPTY (built-in)". + Keep the tmux check on non-Windows unchanged. + - `cli/spawn.go`: the Windows attach hint used `zellij attach` + socket dir. conpty + has no user-facing attach command (the dashboard attaches over loopback). Change + the Windows hint to direct the user to the dashboard (e.g. "Attach from the AO + dashboard") or omit the attach line on Windows. Keep the non-Windows tmux hint + (`tmux attach -t `) unchanged. + - `daemon/wiring_test.go`: remove/replace the `zellij.New(...)` usages and the + `TestDaemonZellijSocketDir...` test. If a sub-test only validated zellij's + DefaultSocketDir helper (now deleted), delete that sub-test (the helper is gone). + For any test that needs a runtime, use `runtimeselect.New(nil)` or + `tmux.New(tmux.Options{})`. Do not weaken coverage of the wiring itself. + - `daemon/lifecycle_wiring.go`: remove any leftover zellij import/comment (the + startSession signature already takes `runtimeselect.Runtime`). + - `terminal/attachment_integration_test.go`: it currently spins up a real zellij + session to test the attach stream end to end. Re-point it at the **tmux** runtime + (available on this Darwin box) so the integration coverage survives: create a tmux + session via `tmux.New(...).Create(...)`, attach via the runtime's `Attach`, assert + the stream behavior, and clean up (kill the session in t.Cleanup). If re-pointing + is impractical, gate it on tmux availability with `exec.LookPath` and skip + otherwise, but prefer making it run against tmux. Keep the test's intent (it + proves the real attach stream streams a live pane and re-attaches at a new size). + +## Step 4 — verify nothing else imports zellij +`grep -rn "runtime/zellij" backend/` must return nothing. Also confirm +`internal/agentlaunch` is still imported by `cli/launch.go` (do NOT delete it). + +## Definition of done (from `backend/`) +- `grep -rn "runtime/zellij" .` returns nothing. +- `go build ./...` ; `GOOS=windows go build ./...` ; `GOOS=linux go build ./...` succeed. +- `go test -race ./...` passes (full suite). The tmux-backed attach integration test + runs (not skipped) and passes against real tmux 3.6b. +- `go vet ./...` clean. +- `grep -rn "—" ` shows no em dashes in your changes. + +## Hard rules +- This removes a whole package and rewires platform selection. Be surgical; keep the + union interface and all consumer interfaces intact (conpty already satisfies them). +- No new go.mod module. Never use em dashes ("—"). Minimal/idiomatic; mark shortcuts + with `ponytail:`. +- Commit on the branch; trailer `Co-Authored-By: Claude Opus 4.8 `. From 6a18394eb7b37338e844825ee35678be46ddcb44 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 24 Jun 2026 00:38:29 +0530 Subject: [PATCH 19/60] feat(runtime): select conpty on Windows, register pty-host subcommand, delete zellij - runtimeselect.New: Windows branch now returns conpty.New(conpty.Options{}) instead of zellij; compile-time assertion updated to conpty.Runtime. - cli/ptyhost.go: new hidden "ao pty-host" subcommand (DisableFlagParsing so agent shell args with leading dashes survive); calls conpty.RunHost and exits with its code. - cli/root.go: wires newPtyHostCommand alongside newLaunchCommand. - cli/doctor.go: Windows terminal-runtime check replaced with a static ConPTY built-in pass; zellij import and checkZellij function removed. - cli/spawn.go: Windows attach hint updated to dashboard message (ConPTY has no CLI attach); zellij import removed. - daemon/lifecycle_wiring.go: stale zellij comment updated to tmux/conpty. - daemon/wiring_test.go: zellij import and TestDaemonZellijSocketDir test removed; TestWiring_StartLifecycleThreadsMessengerIntoLCM now uses tmux.New. - terminal/attachment_integration_test.go: re-pointed at real tmux (TestAttachmentStreamsRealTmuxPane + TestAttachmentReattachAdoptsNewSize); sessions cleaned up in t.Cleanup. - internal/adapters/runtime/zellij: deleted entirely. All three GOOS builds pass; go test -race ./... 1607 passed; go vet clean; grep -rn "runtime/zellij" returns nothing. Co-Authored-By: Claude Opus 4.8 --- .../runtime/runtimeselect/runtimeselect.go | 26 +- .../adapters/runtime/zellij/commands.go | 405 -------- .../adapters/runtime/zellij/process_other.go | 18 - .../runtime/zellij/process_windows.go | 79 -- .../adapters/runtime/zellij/zellij.go | 964 ------------------ .../runtime/zellij/zellij_integration_test.go | 165 --- .../adapters/runtime/zellij/zellij_test.go | 734 ------------- backend/internal/cli/doctor.go | 30 +- backend/internal/cli/ptyhost.go | 29 + backend/internal/cli/root.go | 1 + backend/internal/cli/spawn.go | 8 +- backend/internal/daemon/lifecycle_wiring.go | 2 +- backend/internal/daemon/wiring_test.go | 23 +- .../terminal/attachment_integration_test.go | 57 +- 14 files changed, 68 insertions(+), 2473 deletions(-) delete mode 100644 backend/internal/adapters/runtime/zellij/commands.go delete mode 100644 backend/internal/adapters/runtime/zellij/process_other.go delete mode 100644 backend/internal/adapters/runtime/zellij/process_windows.go delete mode 100644 backend/internal/adapters/runtime/zellij/zellij.go delete mode 100644 backend/internal/adapters/runtime/zellij/zellij_integration_test.go delete mode 100644 backend/internal/adapters/runtime/zellij/zellij_test.go create mode 100644 backend/internal/cli/ptyhost.go diff --git a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go index 8e486db3..3092590f 100644 --- a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go +++ b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go @@ -1,19 +1,18 @@ // Package runtimeselect picks the correct runtime backend by platform: -// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). +// tmux on Darwin/Linux, conpty (ConPTY) on Windows. package runtimeselect import ( "context" "log/slog" - "os" "runtime" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// Runtime is the union interface that both tmux and zellij satisfy. +// Runtime is the union interface that both tmux and conpty satisfy. // It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods // the daemon wires directly, including ports.Attacher (Attach) so the terminal // layer can open a Stream against the selected runtime. @@ -26,22 +25,13 @@ type Runtime interface { // Compile-time assertions: both adapters must implement the union interface. var _ Runtime = (*tmux.Runtime)(nil) -var _ Runtime = (*zellij.Runtime)(nil) +var _ Runtime = (*conpty.Runtime)(nil) -// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. -// log is used only for the Windows zellij socket-dir warning; nil is safe. -func New(log *slog.Logger) Runtime { +// New returns the per-platform runtime: tmux on Darwin/Linux, conpty on Windows. +// log is accepted for signature stability with callers but is currently unused. +func New(_ *slog.Logger) Runtime { if runtime.GOOS != "windows" { return tmux.New(tmux.Options{}) } - // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. - dir := zellij.DefaultSocketDir() - if dir != "" { - if err := os.MkdirAll(dir, 0o700); err != nil { - if log != nil { - log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) - } - } - } - return zellij.New(zellij.Options{SocketDir: dir}) + return conpty.New(conpty.Options{}) } diff --git a/backend/internal/adapters/runtime/zellij/commands.go b/backend/internal/adapters/runtime/zellij/commands.go deleted file mode 100644 index b410bd50..00000000 --- a/backend/internal/adapters/runtime/zellij/commands.go +++ /dev/null @@ -1,405 +0,0 @@ -package zellij - -import ( - "encoding/base64" - "encoding/binary" - "fmt" - "runtime" - "sort" - "strconv" - "strings" - "unicode/utf16" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -const ( - agentPaneName = "agent" - defaultChunkBytes = 16 * 1024 -) - -func versionArgs() []string { - return []string{"--version"} -} - -func createSessionArgs(id, layoutPath string) []string { - clientOptions := embeddedClientOptions() - args := make([]string, 0, 6+len(clientOptions)+6) - args = append(args, - "attach", "--create-background", id, - "options", - "--default-layout", layoutPath, - ) - args = append(args, clientOptions...) - args = append(args, - "--session-serialization", "false", - "--show-startup-tips", "false", - "--show-release-notes", "false", - ) - return args -} - -func listPanesArgs(id string) []string { - return []string{"--session", id, "action", "list-panes", "--all", "--json"} -} - -func pasteArgs(id, paneID, chunk string) []string { - if runtime.GOOS == "windows" { - return []string{"--session", id, "action", "write-chars", "--pane-id", paneID, chunk} - } - return []string{"--session", id, "action", "paste", "--pane-id", paneID, chunk} -} - -func sendEnterArgs(id, paneID string) []string { - return []string{"--session", id, "action", "send-keys", "--pane-id", paneID, "Enter"} -} - -func dumpScreenArgs(id, paneID string) []string { - return []string{"--session", id, "action", "dump-screen", "--pane-id", paneID, "--full"} -} - -func listSessionsArgs() []string { - return []string{"list-sessions", "--no-formatting"} -} - -// deleteSessionArgs builds the teardown command. `delete-session --force` -// kills a running session AND removes its serialized resurrection state in one -// step. Plain `kill-session` is not enough: zellij can keep the session in its -// global resurrection cache as "(EXITED - attach to resurrect)", and any later -// `zellij attach ` (e.g. the terminal mux re-opening a pane) would resurrect -// it — re-running the agent command for a session the daemon already destroyed. -func deleteSessionArgs(id string) []string { - return []string{"delete-session", "--force", id} -} - -func attachArgs(id string) []string { - clientOptions := embeddedClientOptions() - args := make([]string, 0, 3+len(clientOptions)) - args = append(args, - "attach", id, - "options", - ) - args = append(args, clientOptions...) - return args -} - -func embeddedClientOptions() []string { - return []string{ - "--pane-frames", "false", - // The dashboard terminal disables xterm local scrollback and lets the - // embedded zellij client own scrollback. Keep mouse mode on so wheel - // reports reach zellij, while leaving richer pointer behaviors off. - "--mouse-mode", "true", - "--advanced-mouse-actions", "false", - "--mouse-hover-effects", "false", - "--focus-follows-mouse", "false", - "--mouse-click-through", "false", - "--support-kitty-keyboard-protocol", "false", - } -} - -func handleIDValue(sessionID, paneID string) string { - return sessionID + "/" + paneID -} - -func terminalPaneID(id int) string { - return fmt.Sprintf("terminal_%d", id) -} - -func buildLayout(cfg ports.RuntimeConfig, shellPath string) string { - if runtime.GOOS == "windows" { - return directLayoutString(cfg.WorkspacePath, cfg.Argv) - } - spec := shellLaunchSpecFor(shellPath) - shellCommand := shellLaunchCommand(cfg, spec) - return layoutString(cfg.WorkspacePath, shellPath, spec.args, shellCommand) -} - -// windowsLaunchArgv returns the argv zellij executes on Windows to start the -// agent. The trampoline reads the launch spec from AO_LAUNCH_SPEC, so KDL -// args quoting cannot mangle codex's `--config key=value` flags. -func windowsLaunchArgv(launcherBinary string) []string { - command := launcherBinary - if command == "" { - command = "ao" - } - return []string{command, "launch"} -} - -func windowsFallbackShellArgv(shellPath string) []string { - if strings.TrimSpace(shellPath) == "" { - shellPath = "powershell.exe" - } - base := strings.ToLower(filepathBase(shellPath)) - if strings.Contains(base, "cmd") { - return []string{shellPath, "/D", "/Q", "/K"} - } - if strings.Contains(base, "powershell") || strings.Contains(base, "pwsh") { - return []string{shellPath, "-NoLogo", "-NoProfile", "-NoExit"} - } - if strings.Contains(base, "sh") { - return []string{shellPath, "-i"} - } - return []string{shellPath} -} - -// directLayoutString builds a layout that runs argv[0] with argv[1:] as zellij -// `args`, with no intermediate shell. Used on Windows where wrapping the agent -// in powershell/cmd quoting is unsound for arbitrary argv (e.g. codex's -// `--config key="value with spaces"`). -func directLayoutString(workspacePath string, argv []string) string { - command := "" - args := []string{} - if len(argv) > 0 { - command = argv[0] - args = argv[1:] - } - - var b strings.Builder - b.WriteString("layout {\n") - b.WriteString(" cwd ") - b.WriteString(kdlQuote(workspacePath)) - b.WriteString("\n") - b.WriteString(" pane command=") - b.WriteString(kdlQuote(command)) - b.WriteString(" name=") - b.WriteString(kdlQuote(agentPaneName)) - b.WriteString(" borderless=true {\n") - if len(args) > 0 { - b.WriteString(" args ") - b.WriteString(kdlJoin(args)) - b.WriteString("\n") - } - b.WriteString(" }\n") - b.WriteString("}\n") - return b.String() -} - -type shellLaunchSpec struct { - args []string -} - -func shellLaunchSpecFor(shellPath string) shellLaunchSpec { - base := strings.ToLower(filepathBase(shellPath)) - if strings.Contains(base, "cmd") { - return shellLaunchSpec{args: []string{"/D", "/S", "/K"}} - } - if strings.Contains(base, "powershell") || strings.Contains(base, "pwsh") { - return shellLaunchSpec{args: []string{"-NoLogo", "-NoProfile", "-NoExit", "-EncodedCommand"}} - } - return shellLaunchSpec{args: []string{"-lc"}} -} - -func layoutString(workspacePath, shellPath string, shellArgs []string, shellCommand string) string { - return "layout {\n" + - " cwd " + kdlQuote(workspacePath) + "\n" + - " pane command=" + kdlQuote(shellPath) + " name=" + kdlQuote(agentPaneName) + " borderless=true {\n" + - " args " + kdlJoin(shellArgs) + " " + kdlQuote(shellCommand) + "\n" + - " }\n" + - "}\n" -} - -func shellLaunchCommand(cfg ports.RuntimeConfig, spec shellLaunchSpec) string { - if len(spec.args) > 0 && spec.args[0] == "-NoLogo" { - return wrapLaunchCommandPowerShell(cfg) - } - if len(spec.args) > 0 && spec.args[0] == "/D" { - return wrapLaunchCommandCmd(cfg) - } - return wrapLaunchCommandUnix(cfg) -} - -func wrapLaunchCommandUnix(cfg ports.RuntimeConfig) string { - path := cfg.Env["PATH"] - if path == "" { - path = getenv("PATH") - } - - var b strings.Builder - for _, key := range sortedKeys(cfg.Env) { - if key == "PATH" { - continue - } - b.WriteString("export ") - b.WriteString(key) - b.WriteString("=") - b.WriteString(shellQuote(cfg.Env[key])) - b.WriteString("; ") - } - if path != "" { - b.WriteString("export PATH=") - b.WriteString(shellQuote(path)) - b.WriteString("; ") - } - b.WriteString(quoteArgvUnix(cfg.Argv)) - return b.String() -} - -func wrapLaunchCommandPowerShell(cfg ports.RuntimeConfig) string { - path := cfg.Env["PATH"] - if path == "" { - path = getenv("PATH") - } - - var b strings.Builder - for _, key := range sortedKeys(cfg.Env) { - if key == "PATH" { - continue - } - b.WriteString("$env:") - b.WriteString(key) - b.WriteString(" = ") - b.WriteString(psQuote(cfg.Env[key])) - b.WriteString("; ") - } - if path != "" { - b.WriteString("$env:PATH = ") - b.WriteString(psQuote(path)) - b.WriteString("; ") - } - b.WriteString(quoteArgvPowerShell(cfg.Argv)) - return powerShellEncodedCommand(b.String()) -} - -// powerShellEncodedCommand returns the base64'd UTF-16-LE form of script, -// suitable for `powershell.exe -EncodedCommand`. zellij's KDL `args` quoting -// is not robust enough to round-trip arbitrary PowerShell script text through -// a plain `-Command` argv slot, so we hand PowerShell a single opaque base64 -// blob instead. -func powerShellEncodedCommand(script string) string { - words := utf16.Encode([]rune(script)) - buf := make([]byte, len(words)*2) - for i, word := range words { - binary.LittleEndian.PutUint16(buf[i*2:], word) - } - return base64.StdEncoding.EncodeToString(buf) -} - -func wrapLaunchCommandCmd(cfg ports.RuntimeConfig) string { - path := cfg.Env["PATH"] - if path == "" { - path = getenv("PATH") - } - - var b strings.Builder - for _, key := range sortedKeys(cfg.Env) { - if key == "PATH" { - continue - } - b.WriteString("set \"") - b.WriteString(key) - b.WriteString("=") - b.WriteString(cmdQuote(cfg.Env[key])) - b.WriteString("\" && ") - } - if path != "" { - b.WriteString("set \"PATH=") - b.WriteString(cmdQuote(path)) - b.WriteString("\" && ") - } - b.WriteString(quoteArgvCmd(cfg.Argv)) - return b.String() -} - -func validateEnvKeys(env map[string]string) error { - for key := range env { - if !validEnvKey(key) { - return fmt.Errorf("zellij runtime: invalid env key %q", key) - } - } - return nil -} - -func validEnvKey(key string) bool { - if key == "" { - return false - } - for i, r := range key { - if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { - continue - } - if i > 0 && r >= '0' && r <= '9' { - continue - } - return false - } - return true -} - -func sortedKeys(m map[string]string) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -func shellQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" -} - -func psQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", "''") + "'" -} - -func cmdQuote(s string) string { - return strings.ReplaceAll(s, "\"", "\"\"") -} - -// quoteArgvUnix renders argv as a POSIX-shell command, single-quoting each -// argument so a value with spaces stays one word under `sh -lc`. -func quoteArgvUnix(argv []string) string { - parts := make([]string, len(argv)) - for i, a := range argv { - parts[i] = shellQuote(a) - } - return strings.Join(parts, " ") -} - -// quoteArgvPowerShell renders argv for `powershell -Command`. The call operator -// `&` is required so a quoted first token is invoked as a command rather than -// echoed as a string literal. -func quoteArgvPowerShell(argv []string) string { - if len(argv) == 0 { - return "" - } - parts := make([]string, len(argv)) - for i, a := range argv { - parts[i] = psQuote(a) - } - return "& " + strings.Join(parts, " ") -} - -// quoteArgvCmd renders argv for cmd.exe, wrapping each argument in double quotes -// (doubling any embedded quote) so spaces don't split a single argument. -func quoteArgvCmd(argv []string) string { - parts := make([]string, len(argv)) - for i, a := range argv { - parts[i] = "\"" + strings.ReplaceAll(a, "\"", "\"\"") + "\"" - } - return strings.Join(parts, " ") -} - -func kdlQuote(s string) string { - return strconv.Quote(s) -} - -func kdlJoin(args []string) string { - parts := make([]string, 0, len(args)) - for _, arg := range args { - parts = append(parts, kdlQuote(arg)) - } - return strings.Join(parts, " ") -} - -func filepathBase(path string) string { - if path == "" { - return "" - } - i := strings.LastIndexAny(path, `/\`) - if i < 0 { - return path - } - return path[i+1:] -} diff --git a/backend/internal/adapters/runtime/zellij/process_other.go b/backend/internal/adapters/runtime/zellij/process_other.go deleted file mode 100644 index 69e955f3..00000000 --- a/backend/internal/adapters/runtime/zellij/process_other.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build !windows - -package zellij - -import ( - "errors" - "os/exec" -) - -// hideWindow is a no-op off Windows: only Windows pops a console window. -func hideWindow(*exec.Cmd) {} - -// startBackgroundProcess is a stub: the fire-and-forget path is only used by -// the Windows zellij codepath. Non-Windows builds create sessions -// synchronously via runner.Run. -func startBackgroundProcess(env []string, name string, args ...string) error { - return errors.New("zellij runtime: background spawn is windows-only") -} diff --git a/backend/internal/adapters/runtime/zellij/process_windows.go b/backend/internal/adapters/runtime/zellij/process_windows.go deleted file mode 100644 index 51301963..00000000 --- a/backend/internal/adapters/runtime/zellij/process_windows.go +++ /dev/null @@ -1,79 +0,0 @@ -//go:build windows - -package zellij - -import ( - "os/exec" - "strings" - "syscall" - - "golang.org/x/sys/windows" -) - -// hideWindow suppresses the console window for a console-subsystem child so -// frequent zellij calls (e.g. the reaper's list-sessions) don't flash on Windows. -func hideWindow(cmd *exec.Cmd) { - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: true, - CreationFlags: windows.CREATE_NO_WINDOW, - } -} - -func startBackgroundProcess(env []string, name string, args ...string) error { - script := "Start-Process -FilePath " + psQuote(name) + " -ArgumentList " + psQuote(windowsCommandLine(args)) + " -WindowStyle Hidden" - cmd := exec.Command("powershell.exe", "-NoLogo", "-NoProfile", "-EncodedCommand", powerShellEncodedCommand(script)) - cmd.Env = env - cmd.SysProcAttr = &syscall.SysProcAttr{ - // CREATE_NO_WINDOW, not CREATE_NEW_CONSOLE: the latter creates a console - // then hides it, which flashed a window. - CreationFlags: windows.CREATE_NO_WINDOW, - HideWindow: true, - } - if err := cmd.Start(); err != nil { - return err - } - go func() { _ = cmd.Wait() }() - return nil -} - -func windowsCommandLine(args []string) string { - quoted := make([]string, len(args)) - for i, arg := range args { - quoted[i] = windowsQuoteArg(arg) - } - return strings.Join(quoted, " ") -} - -func windowsQuoteArg(arg string) string { - if arg == "" { - return `""` - } - if !strings.ContainsAny(arg, " \t\"") { - return arg - } - - var b strings.Builder - b.WriteByte('"') - backslashes := 0 - for _, r := range arg { - switch r { - case '\\': - backslashes++ - case '"': - b.WriteString(strings.Repeat(`\`, backslashes*2+1)) - b.WriteRune(r) - backslashes = 0 - default: - if backslashes > 0 { - b.WriteString(strings.Repeat(`\`, backslashes)) - backslashes = 0 - } - b.WriteRune(r) - } - } - if backslashes > 0 { - b.WriteString(strings.Repeat(`\`, backslashes*2)) - } - b.WriteByte('"') - return b.String() -} diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go deleted file mode 100644 index d7a84516..00000000 --- a/backend/internal/adapters/runtime/zellij/zellij.go +++ /dev/null @@ -1,964 +0,0 @@ -// 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/adapters/runtime/ptyexec" - "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) -var _ ports.Attacher = (*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")) -} - -// Attach opens a fresh attach Stream by spawning the zellij attach client on a -// local PTY, sized rows x cols from birth when known. On Windows the per-session -// env block (ZELLIJ_SOCKET_DIR) is applied to the child. ctx cancellation closes -// the PTY. -func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { - argv, env, err := r.attachCommand(handle) - if err != nil { - return nil, err - } - return ptyexec.Spawn(ctx, argv, env, rows, cols) -} - -// 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 } diff --git a/backend/internal/adapters/runtime/zellij/zellij_integration_test.go b/backend/internal/adapters/runtime/zellij/zellij_integration_test.go deleted file mode 100644 index ac6a33e4..00000000 --- a/backend/internal/adapters/runtime/zellij/zellij_integration_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package zellij - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -func TestRuntimeIntegration(t *testing.T) { - if _, err := exec.LookPath("zellij"); err != nil { - t.Skip("zellij unavailable") - } - - ctx := context.Background() - id := "ao_itest_zj" - socketDir := tempSocketDir(t, "ao-zj-itest-") - configDir := t.TempDir() - opts := Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: configDir} - if runtime.GOOS == "windows" { - opts.Timeout = 30 * time.Second - opts.LauncherBinary = buildAOForIntegration(t) - opts.Shell = "cmd.exe" - } - r := New(opts) - _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) - argv := []string{"sh", "-lc", "printf ready-$AO_SESSION_ID\\n; exec sh -i"} - sendCommand := "echo hello-from-zellij" - if runtime.GOOS == "windows" { - argv = []string{"cmd.exe", "/D", "/Q", "/K", "echo ready-%AO_SESSION_ID%"} - } - - h, err := r.Create(ctx, ports.RuntimeConfig{ - SessionID: "ao_itest_zj", - WorkspacePath: t.TempDir(), - Argv: argv, - Env: map[string]string{"AO_SESSION_ID": id}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - defer r.Destroy(ctx, h) - - alive, err := r.IsAlive(ctx, h) - if err != nil { - t.Fatalf("IsAlive: %v", err) - } - if !alive { - t.Fatal("alive = false, want true") - } - prefixAlive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: "ao_itest"}) - if err != nil { - t.Fatalf("IsAlive prefix: %v", err) - } - if prefixAlive { - t.Fatal("prefix handle reported alive; zellij session matching is not exact") - } - - out := waitForRuntimeOutput(t, r, h, "ready-") - if !strings.Contains(out, "ready-") { - t.Fatalf("output = %q, want ready output", out) - } - if err := r.SendMessage(ctx, h, sendCommand); err != nil { - t.Fatalf("SendMessage: %v", err) - } - out = waitForRuntimeOutput(t, r, h, "hello-from-zellij") - if !strings.Contains(out, "hello-from-zellij") { - t.Fatalf("output = %q, want sent command output", out) - } - - if err := r.Destroy(ctx, h); err != nil { - t.Fatalf("Destroy: %v", err) - } - alive, err = r.IsAlive(ctx, h) - if err != nil { - t.Fatalf("IsAlive after destroy: %v", err) - } - if alive { - t.Fatal("alive after destroy = true, want false") - } -} - -func waitForRuntimeOutput(t *testing.T, r *Runtime, h ports.RuntimeHandle, want string) string { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - var out string - for time.Now().Before(deadline) { - var err error - out, err = r.GetOutput(context.Background(), h, 30) - if err != nil { - t.Fatalf("GetOutput: %v", err) - } - if strings.Contains(out, want) { - return out - } - time.Sleep(100 * time.Millisecond) - } - return out -} - -func buildAOForIntegration(t *testing.T) string { - t.Helper() - out := filepath.Join(t.TempDir(), "ao.exe") - cmd := exec.Command("go", "build", "-o", out, "./cmd/ao") - cmd.Dir = filepath.Clean(filepath.Join("..", "..", "..", "..")) - if raw, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("build ao test launcher: %v: %s", err, strings.TrimSpace(string(raw))) - } - return out -} - -func tempSocketDir(t *testing.T, pattern string) string { - t.Helper() - parent := os.TempDir() - if runtime.GOOS != "windows" { - parent = "/tmp" - } - socketDir, err := os.MkdirTemp(parent, pattern) - if err != nil { - t.Fatalf("mkdir socket dir: %v", err) - } - t.Cleanup(func() { _ = os.RemoveAll(socketDir) }) - return socketDir -} - -func TestRuntimeIntegrationUsesExactSessionParsing(t *testing.T) { - if _, err := exec.LookPath("zellij"); err != nil { - t.Skip("zellij unavailable") - } - if runtime.GOOS == "windows" { - t.Skip("exact session parsing is covered by TestRuntimeIntegration on Windows") - } - - ctx := context.Background() - socketDir := tempSocketDir(t, "ao-zj-exact-itest-") - r := New(Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: t.TempDir()}) - longID := "ao_zj_exact_long" - prefixID := "ao_zj_exact" - _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) - _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID}) - - h, err := r.Create(ctx, ports.RuntimeConfig{ - SessionID: "ao_zj_exact_long", - WorkspacePath: t.TempDir(), - Argv: []string{"sh", "-lc", "printf ready\\n; exec sh -i"}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - defer r.Destroy(ctx, h) - - alive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID}) - if err != nil { - t.Fatalf("IsAlive prefix: %v", err) - } - if alive { - t.Fatal("prefix handle reported alive; zellij session matching is not exact") - } -} diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go deleted file mode 100644 index 1a6d6a87..00000000 --- a/backend/internal/adapters/runtime/zellij/zellij_test.go +++ /dev/null @@ -1,734 +0,0 @@ -package zellij - -import ( - "context" - "errors" - "os/exec" - "reflect" - "runtime" - "strings" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -func TestNewDefaultsToPortableShell(t *testing.T) { - t.Setenv("SHELL", "") - r := New(Options{}) - want := "/bin/sh" - if runtime.GOOS == "windows" { - want = "powershell.exe" - } - if got := r.shell; got != want { - t.Fatalf("default shell = %q, want %q", got, want) - } -} - -func TestZellijCommandEnvNormalizesBrowserTerminalColors(t *testing.T) { - got := zellijCommandEnv( - []string{"NO_COLOR=1", "TERM=dumb", "COLORTERM=", "KEEP=yes"}, - []string{"ZELLIJ_SOCKET_DIR=/tmp/zj"}, - ) - - if runtime.GOOS == "windows" { - if !contains(got, "NO_COLOR=1") { - t.Fatalf("windows env = %#v, want NO_COLOR preserved", got) - } - return - } - - if containsKey(got, "NO_COLOR") { - t.Fatalf("NO_COLOR survived env normalization: %#v", got) - } - for _, want := range []string{"KEEP=yes", "TERM=xterm-256color", "COLORTERM=truecolor", "ZELLIJ_SOCKET_DIR=/tmp/zj"} { - if !contains(got, want) { - t.Fatalf("env missing %q in %#v", want, got) - } - } -} - -func expectedZellijEnv(socketDir string) []string { - env := []string{} - if runtime.GOOS != "windows" { - env = append(env, "TERM=xterm-256color", "COLORTERM=truecolor") - } - if socketDir != "" { - env = append(env, "ZELLIJ_SOCKET_DIR="+socketDir) - } - return env -} - -func expectedAttachEnvPrefix() []string { - if runtime.GOOS == "windows" { - return []string{} - } - return []string{"env", "-u", "NO_COLOR", "TERM=xterm-256color", "COLORTERM=truecolor"} -} - -func contains(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false -} - -func containsKey(values []string, key string) bool { - prefix := key + "=" - for _, value := range values { - if strings.HasPrefix(value, prefix) { - return true - } - } - return false -} - -func TestCommandBuilders(t *testing.T) { - embeddedOptions := []string{ - "--pane-frames", "false", - "--mouse-mode", "true", - "--advanced-mouse-actions", "false", - "--mouse-hover-effects", "false", - "--focus-follows-mouse", "false", - "--mouse-click-through", "false", - "--support-kitty-keyboard-protocol", "false", - } - if got, want := versionArgs(), []string{"--version"}; !reflect.DeepEqual(got, want) { - t.Fatalf("versionArgs = %#v, want %#v", got, want) - } - wantCreate := append([]string{"attach", "--create-background", "sess-1", "options", "--default-layout", "/tmp/layout.kdl"}, embeddedOptions...) - wantCreate = append(wantCreate, "--session-serialization", "false", "--show-startup-tips", "false", "--show-release-notes", "false") - if got, want := createSessionArgs("sess-1", "/tmp/layout.kdl"), wantCreate; !reflect.DeepEqual(got, want) { - t.Fatalf("createSessionArgs = %#v, want %#v", got, want) - } - if got, want := listPanesArgs("sess-1"), []string{"--session", "sess-1", "action", "list-panes", "--all", "--json"}; !reflect.DeepEqual(got, want) { - t.Fatalf("listPanesArgs = %#v, want %#v", got, want) - } - pasteAction := "paste" - if runtime.GOOS == "windows" { - pasteAction = "write-chars" - } - if got, want := pasteArgs("sess-1", "terminal_0", "hello"), []string{"--session", "sess-1", "action", pasteAction, "--pane-id", "terminal_0", "hello"}; !reflect.DeepEqual(got, want) { - t.Fatalf("pasteArgs = %#v, want %#v", got, want) - } - if got, want := dumpScreenArgs("sess-1", "terminal_0"), []string{"--session", "sess-1", "action", "dump-screen", "--pane-id", "terminal_0", "--full"}; !reflect.DeepEqual(got, want) { - t.Fatalf("dumpScreenArgs = %#v, want %#v", got, want) - } - // delete-session --force (not kill-session): teardown must also purge the - // serialized resurrection state, or a later `zellij attach` re-creates the - // session — and re-runs its agent — after the daemon destroyed it. - if got, want := deleteSessionArgs("sess-1"), []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { - t.Fatalf("deleteSessionArgs = %#v, want %#v", got, want) - } - wantAttach := append([]string{"attach", "sess-1", "options"}, embeddedOptions...) - if got, want := attachArgs("sess-1"), wantAttach; !reflect.DeepEqual(got, want) { - t.Fatalf("attachArgs = %#v, want %#v", got, want) - } -} - -func TestZellijSessionNameSanitizesIssueRefs(t *testing.T) { - got, err := zellijSessionName("repo/issue#42.1") - if err != nil { - t.Fatalf("zellijSessionName: %v", err) - } - if err := validateSessionID(got); err != nil { - t.Fatalf("sanitized id %q is invalid: %v", got, err) - } - if !strings.HasPrefix(got, "repo-issue-42-1-") { - t.Fatalf("sanitized id = %q, want readable prefix", got) - } - if got == "repo/issue#42.1" { - t.Fatal("sanitized id still contains raw unsafe characters") - } -} - -// SessionName must return the exact name Create registers a session under, so -// callers that print an attach hint (e.g. `ao spawn`) reference the real -// session. A short, conforming id passes through; a long one is sanitised to a -// different name — printing the raw id there would send users to a missing -// session. -func TestSessionNameMatchesCreateNaming(t *testing.T) { - short := "myproj-1" - if got := SessionName(short); got != short { - t.Fatalf("SessionName(%q) = %q, want it unchanged", short, got) - } - - long := domain.SessionID(strings.Repeat("x", 60) + "-1") - viaCreate, err := zellijSessionName(long) - if err != nil { - t.Fatalf("zellijSessionName: %v", err) - } - if got := SessionName(string(long)); got != viaCreate { - t.Fatalf("SessionName = %q, but Create uses %q", got, viaCreate) - } - if SessionName(string(long)) == string(long) { - t.Fatal("expected a long id to be sanitised to a different name") - } -} - -func TestValidateSessionAndPaneID(t *testing.T) { - for _, id := range []string{"sess-1", "S_2", "abc123"} { - if err := validateSessionID(id); err != nil { - t.Fatalf("validateSessionID(%q): %v", id, err) - } - } - for _, id := range []string{"", "sess.1", "sess/1", "$(boom)", "with space"} { - if err := validateSessionID(id); err == nil { - t.Fatalf("validateSessionID(%q): got nil, want error", id) - } - } - for _, id := range []string{"terminal_0", "terminal_42"} { - if err := validatePaneID(id); err != nil { - t.Fatalf("validatePaneID(%q): %v", id, err) - } - } - for _, id := range []string{"", "0", "plugin_0", "terminal_x", "terminal_1/2"} { - if err := validatePaneID(id); err == nil { - t.Fatalf("validatePaneID(%q): got nil, want error", id) - } - } -} - -func TestHandleID(t *testing.T) { - session, pane, err := handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7"}) - if err != nil { - t.Fatalf("handleID: %v", err) - } - if session != "sess-1" || pane != "terminal_7" { - t.Fatalf("handleID = %q/%q", session, pane) - } -} - -func TestBuildLayoutExportsEnvAndRunsAgentCommand(t *testing.T) { - oldGetenv := getenv - getenv = func(key string) string { - if key == "PATH" { - return "/usr/bin:/bin" - } - return "" - } - defer func() { getenv = oldGetenv }() - - got := buildLayout(ports.RuntimeConfig{WorkspacePath: "/tmp/ws", Argv: []string{"ao", "run"}, Env: map[string]string{ - "AO_SESSION_ID": "sess-1", - "ODD": "can't", - "PATH": "/custom/bin:/usr/bin", - }}, "/bin/zsh") - if runtime.GOOS == "windows" { - for _, want := range []string{ - `cwd "/tmp/ws"`, - `pane command="ao" name="agent" borderless=true`, - `args "run"`, - } { - if !strings.Contains(got, want) { - t.Fatalf("direct windows layout missing %q in %q", want, got) - } - } - return - } - - for _, want := range []string{ - `cwd "/tmp/ws"`, - `pane command="/bin/zsh" name="agent" borderless=true`, - "export AO_SESSION_ID='sess-1';", - "export ODD='can'\\\\''t';", - "export PATH='/custom/bin:/usr/bin';", - "'ao' 'run'", - } { - if !strings.Contains(got, want) { - t.Fatalf("layout missing %q in %q", want, got) - } - } - if strings.Contains(got, "exec '/bin/zsh' -i") { - t.Fatalf("layout kept pane alive after agent exit: %q", got) - } -} - -func TestBuildLayoutUsesPowerShellLaunchOnWindowsShells(t *testing.T) { - oldGetenv := getenv - getenv = func(key string) string { - if key == "PATH" { - return `C:\custom\bin` - } - return "" - } - defer func() { getenv = oldGetenv }() - - got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, Argv: []string{"Write-Host", "ready"}, Env: map[string]string{ - "AO_SESSION_ID": "sess-1", - }}, `C:\Program Files\PowerShell\7\pwsh.exe`) - if runtime.GOOS == "windows" { - for _, want := range []string{ - `cwd "C:\\ws"`, - `pane command="Write-Host" name="agent" borderless=true`, - `args "ready"`, - } { - if !strings.Contains(got, want) { - t.Fatalf("direct windows layout missing %q in %q", want, got) - } - } - return - } - - for _, want := range []string{ - `pane command="C:\\Program Files\\PowerShell\\7\\pwsh.exe" name="agent" borderless=true`, - `args "-NoLogo" "-NoProfile" "-NoExit" "-EncodedCommand"`, - } { - if !strings.Contains(got, want) { - t.Fatalf("powershell layout missing %q in %q", want, got) - } - } -} - -func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) { - oldGetenv := getenv - getenv = func(key string) string { - return "" - } - defer func() { getenv = oldGetenv }() - - got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, Argv: []string{"echo", "ready"}, Env: map[string]string{ - "AO_SESSION_ID": "sess-1", - }}, `C:\Windows\System32\cmd.exe`) - if runtime.GOOS == "windows" { - for _, want := range []string{ - `cwd "C:\\ws"`, - `pane command="echo" name="agent" borderless=true`, - `args "ready"`, - } { - if !strings.Contains(got, want) { - t.Fatalf("direct windows layout missing %q in %q", want, got) - } - } - return - } - - for _, want := range []string{ - `pane command="C:\\Windows\\System32\\cmd.exe" name="agent" borderless=true`, - `args "/D" "/S" "/K"`, - `AO_SESSION_ID=sess-1`, - `\"echo\" \"ready\"`, - } { - if !strings.Contains(got, want) { - t.Fatalf("cmd layout missing %q in %q", want, got) - } - } -} - -func TestCreateRejectsInvalidEnvKeys(t *testing.T) { - r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh"}) - r.runner = &fakeRunner{} - _, err := r.Create(context.Background(), ports.RuntimeConfig{ - SessionID: "sess-1", - WorkspacePath: "/tmp/ws", - Argv: []string{"echo", "ready"}, - Env: map[string]string{"BAD KEY": "x"}, - }) - if err == nil || !strings.Contains(err.Error(), "invalid env key") { - t.Fatalf("Create err = %v, want invalid env key", err) - } -} - -func TestCreateStartsSessionAndDiscoversPane(t *testing.T) { - panesOut := []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`) - outputs := [][]byte{ - []byte("zellij 0.44.3"), - nil, - nil, - panesOut, - } - if runtime.GOOS == "windows" { - outputs = append(outputs, panesOut) - } - outputs = append(outputs, []byte("sess-1 [Created 1s ago] \n")) - fr := &fakeRunner{outputs: outputs} - r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh", SocketDir: "/tmp/zj", ConfigDir: "/tmp/cfg"}) - r.runner = fr - - handle, err := r.Create(context.Background(), ports.RuntimeConfig{ - SessionID: "sess-1", - WorkspacePath: "/tmp/ws", - Argv: []string{"echo", "ready"}, - Env: map[string]string{"AO_SESSION_ID": "sess-1"}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - if handle != (ports.RuntimeHandle{ID: "sess-1/terminal_3"}) { - t.Fatalf("handle = %+v, want zellij handle", handle) - } - wantCalls := 5 - if runtime.GOOS == "windows" { - wantCalls = 6 - } - if len(fr.calls) != wantCalls { - t.Fatalf("calls = %d, want %d", len(fr.calls), wantCalls) - } - if got, want := fr.calls[0].args, []string{"--config-dir", "/tmp/cfg", "--version"}; !reflect.DeepEqual(got, want) { - t.Fatalf("version args = %#v, want %#v", got, want) - } - if got, want := fr.calls[1].args, []string{"--config-dir", "/tmp/cfg", "delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { - t.Fatalf("delete args = %#v, want %#v", got, want) - } - if got := fr.calls[2].args[:5]; !reflect.DeepEqual(got, []string{"--config-dir", "/tmp/cfg", "attach", "--create-background", "sess-1"}) { - t.Fatalf("create args prefix = %#v", got) - } - if got := fr.calls[3].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listPanesArgs("sess-1")...)) { - t.Fatalf("list panes args = %#v", got) - } - listSessionsCall := 4 - if runtime.GOOS == "windows" { - if got := fr.calls[4].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listPanesArgs("sess-1")...)) { - t.Fatalf("ready list panes args = %#v", got) - } - listSessionsCall = 5 - } - if got := fr.calls[listSessionsCall].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listSessionsArgs()...)) { - t.Fatalf("list sessions args = %#v", got) - } - if got, want := fr.calls[0].env, expectedZellijEnv("/tmp/zj"); !reflect.DeepEqual(got, want) { - t.Fatalf("env = %#v, want %#v", got, want) - } -} - -func TestCreateClearsStaleSessionBeforeCreating(t *testing.T) { - panesOut := []byte(`[{"id":1,"is_plugin":false,"title":"agent"}]`) - outputs := [][]byte{ - []byte("zellij 0.44.3"), - nil, - nil, - panesOut, - } - if runtime.GOOS == "windows" { - outputs = append(outputs, panesOut) - } - outputs = append(outputs, []byte("sess-1 [Created 1s ago] \n")) - fr := &fakeRunner{outputs: outputs} - r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh"}) - r.runner = fr - - if _, err := r.Create(context.Background(), ports.RuntimeConfig{ - SessionID: "sess-1", - WorkspacePath: "/tmp/ws", - Argv: []string{"echo", "ready"}, - }); err != nil { - t.Fatalf("Create: %v", err) - } - - wantCalls := 5 - if runtime.GOOS == "windows" { - wantCalls = 6 - } - if len(fr.calls) != wantCalls { - t.Fatalf("calls = %d, want %d", len(fr.calls), wantCalls) - } - if got, want := fr.calls[1].args, []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { - t.Fatalf("delete args = %#v, want %#v", got, want) - } - if got := fr.calls[2].args[:3]; !reflect.DeepEqual(got, []string{"attach", "--create-background", "sess-1"}) { - t.Fatalf("create args prefix = %#v", got) - } -} - -func TestAttachCommandUsesEmbeddedClientOptions(t *testing.T) { - r := New(Options{}) - args, _, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) - if err != nil { - t.Fatalf("AttachCommand: %v", err) - } - embeddedOptions := []string{ - "--pane-frames", "false", - "--mouse-mode", "true", - "--advanced-mouse-actions", "false", - "--mouse-hover-effects", "false", - "--focus-follows-mouse", "false", - "--mouse-click-through", "false", - "--support-kitty-keyboard-protocol", "false", - } - if runtime.GOOS == "windows" { - joined := strings.Join(args, " ") - for _, want := range embeddedOptions { - if !strings.Contains(joined, want) { - t.Fatalf("windows attach command missing %q: %#v", want, args) - } - } - return - } - want := append(expectedAttachEnvPrefix(), r.binary, "attach", "sess-1", "options") - want = append(want, embeddedOptions...) - if !reflect.DeepEqual(args, want) { - t.Fatalf("AttachCommand = %#v, want %#v", args, want) - } -} - -func TestAttachCommandUsesSocketDir(t *testing.T) { - r := New(Options{SocketDir: "/tmp/zj"}) - args, _, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) - if err != nil { - t.Fatalf("AttachCommand: %v", err) - } - if runtime.GOOS == "windows" { - if got, want := args[0], r.binary; got != want { - t.Fatalf("attach binary = %q, want %q", got, want) - } - return - } - if got, want := args[:6], []string{"env", "-u", "NO_COLOR", "TERM=xterm-256color", "COLORTERM=truecolor", "ZELLIJ_SOCKET_DIR=/tmp/zj"}; !reflect.DeepEqual(got, want) { - t.Fatalf("attach prefix = %#v, want %#v", got, want) - } - if got, want := args[6], r.binary; got != want { - t.Fatalf("attach binary = %q, want %q", got, want) - } -} - -func TestFindAgentPaneRetriesTransientErrors(t *testing.T) { - fr := &fakeRunner{outputs: [][]byte{[]byte("boom"), []byte(`[{"id":0,"is_plugin":false,"title":"agent"}]`)}} - r := New(Options{Timeout: time.Second}) - r.runner = fr - - got, err := r.findAgentPane(context.Background(), "sess-1") - if err != nil { - t.Fatalf("findAgentPane: %v", err) - } - if got != "terminal_0" { - t.Fatalf("findAgentPane = %q, want terminal_0", got) - } - if len(fr.calls) != 2 { - t.Fatalf("calls = %d, want 2", len(fr.calls)) - } -} - -func TestParseVersion(t *testing.T) { - for _, tc := range []struct { - in string - want semver - }{ - {in: "zellij 0.44.3", want: semver{0, 44, 3}}, - {in: "zellij v1.2.3\n", want: semver{1, 2, 3}}, - {in: "zellij 0.44.3-dev", want: semver{0, 44, 3}}, - } { - got, err := parseVersion(tc.in) - if err != nil { - t.Fatalf("parseVersion(%q): %v", tc.in, err) - } - if got != tc.want { - t.Fatalf("parseVersion(%q) = %v, want %v", tc.in, got, tc.want) - } - } - if _, err := parseVersion("zellij nope"); err == nil { - t.Fatal("parseVersion invalid: got nil, want error") - } - if compareVersion(semver{0, 44, 2}, semver{0, 44, 3}) >= 0 { - t.Fatal("compareVersion should order 0.44.2 before 0.44.3") - } - if got := RequiredVersion(); got != "0.44.3" { - t.Fatalf("RequiredVersion = %q, want 0.44.3", got) - } - if got, err := CheckVersionOutput("zellij 0.44.3"); err != nil || got != "0.44.3" { - t.Fatalf("CheckVersionOutput supported = %q, %v", got, err) - } - if _, err := CheckVersionOutput("zellij 0.44.2"); err == nil { - t.Fatal("CheckVersionOutput unsupported: got nil error") - } -} - -func TestSendMessageChunksAndSendsEnter(t *testing.T) { - fr := &fakeRunner{} - r := New(Options{Timeout: time.Second, ChunkSize: 5}) - r.runner = fr - - if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, "hello世界"); err != nil { - t.Fatalf("SendMessage: %v", err) - } - if len(fr.calls) != 4 { - t.Fatalf("calls = %d, want 4", len(fr.calls)) - } - if got, want := fr.calls[0].args, pasteArgs("sess-1", "terminal_0", "hello"); !reflect.DeepEqual(got, want) { - t.Fatalf("paste 1 args = %#v, want %#v", got, want) - } - if got, want := fr.calls[1].args, pasteArgs("sess-1", "terminal_0", "世"); !reflect.DeepEqual(got, want) { - t.Fatalf("paste 2 args = %#v, want %#v", got, want) - } - if got, want := fr.calls[2].args, pasteArgs("sess-1", "terminal_0", "界"); !reflect.DeepEqual(got, want) { - t.Fatalf("paste 3 args = %#v, want %#v", got, want) - } - if got, want := fr.calls[3].args, sendEnterArgs("sess-1", "terminal_0"); !reflect.DeepEqual(got, want) { - t.Fatalf("enter args = %#v, want %#v", got, want) - } -} - -func TestGetOutputTrimsLines(t *testing.T) { - fr := &fakeRunner{outputs: [][]byte{[]byte("one\ntwo\nthree\n")}} - r := New(Options{Timeout: time.Second}) - r.runner = fr - - out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, 2) - if err != nil { - t.Fatalf("GetOutput: %v", err) - } - if out != "two\nthree\n" { - t.Fatalf("output = %q, want last two lines", out) - } -} - -func TestGetOutputTrimsTrailingScreenPaddingBeforeTailing(t *testing.T) { - fr := &fakeRunner{outputs: [][]byte{[]byte("ready\nprompt> echo hi\nhi\n\n\n\n")}} - r := New(Options{Timeout: time.Second}) - r.runner = fr - - out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, 2) - if err != nil { - t.Fatalf("GetOutput: %v", err) - } - if out != "prompt> echo hi\nhi\n" { - t.Fatalf("output = %q, want last non-padding lines", out) - } -} - -func TestIsAliveParsesNoFormattingOutput(t *testing.T) { - fr := &fakeRunner{outputs: [][]byte{[]byte("sess-1 [Created 1s ago] \nold [Created 2s ago] (EXITED - attach to resurrect)\n")}} - r := New(Options{Timeout: time.Second}) - r.runner = fr - - alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}) - if err != nil { - t.Fatalf("IsAlive: %v", err) - } - if !alive { - t.Fatal("alive = false, want true") - } - if sessionListedAlive("sess-1-long [Created 1s ago]", "sess-1") { - t.Fatal("prefix matched as alive") - } - if sessionListedAlive("sess-1 [Created 1s ago] (EXITED - attach to resurrect)", "sess-1") { - t.Fatal("exited session matched as alive") - } -} - -// IsAlive may treat a non-zero list-sessions ONLY as "not alive" when zellij -// says no sessions exist at all. Any other exit failure is a probe error: the -// reaper reports it as a failed probe and the LCM must never read it as death. -func TestIsAliveTreatsNoSessionsExitAsNotAlive(t *testing.T) { - fr := &fakeRunner{outputs: [][]byte{[]byte("No active zellij sessions found.")}, err: &exec.ExitError{}} - r := New(Options{Timeout: time.Second}) - r.runner = fr - - alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}) - if err != nil { - t.Fatalf("IsAlive: %v", err) - } - if alive { - t.Fatal("alive = true, want false") - } -} - -func TestIsAliveReportsOtherExitFailuresAsProbeErrors(t *testing.T) { - fr := &fakeRunner{outputs: [][]byte{[]byte("thread 'main' panicked")}, err: &exec.ExitError{}} - r := New(Options{Timeout: time.Second}) - r.runner = fr - - alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}) - if err == nil { - t.Fatal("IsAlive: got nil error, want probe failure — a failed probe must not read as dead") - } - if alive { - t.Fatal("alive = true on probe failure") - } -} - -func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { - fr := &fakeRunner{outputs: [][]byte{[]byte("No active zellij sessions found.")}, err: &exec.ExitError{}} - r := New(Options{Timeout: time.Second}) - r.runner = fr - - if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}); err != nil { - t.Fatalf("Destroy: %v", err) - } - if len(fr.calls) != 1 || fr.calls[0].args[0] != "delete-session" { - t.Fatalf("calls = %#v, want only delete-session", fr.calls) - } -} - -func TestDestroyReportsUnexpectedExitFailures(t *testing.T) { - fr := &fakeRunner{outputs: [][]byte{[]byte("permission denied")}, err: &exec.ExitError{}} - r := New(Options{Timeout: time.Second}) - r.runner = fr - - if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}); err == nil { - t.Fatal("Destroy: got nil, want unexpected delete-session failure") - } -} - -// Destroy must delete the session's serialized state, not merely kill it: a -// killed-but-cached session is resurrected (agent re-run included) by any later -// `zellij attach`, bringing a terminated session's runtime back to life. -func TestDestroyForceDeletesSerializedSession(t *testing.T) { - fr := &fakeRunner{} - r := New(Options{Timeout: time.Second}) - r.runner = fr - - if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}); err != nil { - t.Fatalf("Destroy: %v", err) - } - if len(fr.calls) != 1 { - t.Fatalf("calls = %d, want 1", len(fr.calls)) - } - if got, want := fr.calls[0].args, []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { - t.Fatalf("destroy args = %#v, want %#v", got, want) - } -} - -func TestGetOutputValidatesLines(t *testing.T) { - r := New(Options{Timeout: time.Second}) - _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, 0) - if err == nil { - t.Fatal("GetOutput lines=0: got nil, want error") - } -} - -type fakeRunner struct { - calls []runnerCall - outputs [][]byte - err error -} - -type runnerCall struct { - env []string - name string - args []string -} - -func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { - f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) - var out []byte - if len(f.outputs) > 0 { - out = f.outputs[0] - f.outputs = f.outputs[1:] - } - if f.err != nil { - return out, f.err - } - return out, nil -} - -func (f *fakeRunner) Start(env []string, name string, args ...string) error { - f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) - if len(f.outputs) > 0 { - f.outputs = f.outputs[1:] - } - return f.err -} - -func TestCommandErrorUnwraps(t *testing.T) { - base := errors.New("base") - err := commandError{err: base, output: "details"} - if !errors.Is(err, base) { - t.Fatal("commandError should unwrap base error") - } - if !strings.Contains(err.Error(), "details") { - t.Fatalf("error = %q, want output details", err.Error()) - } -} diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go index a52fa521..96492e32 100644 --- a/backend/internal/cli/doctor.go +++ b/backend/internal/cli/doctor.go @@ -19,7 +19,6 @@ import ( "github.com/spf13/cobra" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" "github.com/aoagents/agent-orchestrator/backend/internal/config" ) @@ -291,11 +290,16 @@ func (c *commandContext) checkGit(ctx context.Context) doctorCheck { return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; supports worktrees)", path, version)} } -// checkTerminalRuntime checks for the runtime multiplexer used on this platform: -// tmux on Darwin/Linux, zellij on Windows. +// checkTerminalRuntime checks the runtime multiplexer used on this platform: +// tmux on Darwin/Linux, ConPTY (built-in) on Windows. func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { if runtime.GOOS == "windows" { - return c.checkZellij(ctx) + return doctorCheck{ + Level: doctorPass, + Section: doctorSectionTools, + Name: "conpty", + Message: "ConPTY (built-in): no external terminal multiplexer required on Windows", + } } return c.checkTmux(ctx) } @@ -318,24 +322,6 @@ func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s)", path, version)} } -func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { - path, err := c.deps.LookPath("zellij") - if err != nil || path == "" { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "zellij", Message: "not found in PATH"} - } - reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) - defer cancel() - out, err := c.deps.CommandOutput(reqCtx, path, "--version") - if err != nil { - return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} - } - version, err := zellij.CheckVersionOutput(string(out)) - if err != nil { - return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} - } - return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s (version %s; require >= %s)", path, version, zellij.RequiredVersion())} -} - // checkHooksLog surfaces recent agent hook delivery failures. `ao hooks` // callbacks deliberately swallow errors (a hook must never break the user's // agent), so $AO_DATA_DIR/hooks.log is the only place a dead activity feed diff --git a/backend/internal/cli/ptyhost.go b/backend/internal/cli/ptyhost.go new file mode 100644 index 00000000..d9265975 --- /dev/null +++ b/backend/internal/cli/ptyhost.go @@ -0,0 +1,29 @@ +package cli + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty" +) + +// newPtyHostCommand registers the "ao pty-host" hidden subcommand that the +// conpty runtime spawns on Windows to host a ConPTY session over loopback TCP. +// DisableFlagParsing ensures agent shell args with leading dashes are not +// consumed by cobra before being passed to RunHost. +func newPtyHostCommand() *cobra.Command { + return &cobra.Command{ + Use: "pty-host", + Short: "Run a ConPTY pty-host process (internal)", + Hidden: true, + DisableFlagParsing: true, + RunE: func(_ *cobra.Command, args []string) error { + code := conpty.RunHost(args, os.Stdout) + if code != 0 { + os.Exit(code) + } + return nil + }, + } +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 3bb1fced..da2db9c0 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -188,6 +188,7 @@ func NewRootCommand(deps Deps) *cobra.Command { root.AddCommand(newPreviewCommand(ctx)) root.AddCommand(newHooksCommand(ctx)) root.AddCommand(newLaunchCommand(ctx)) + root.AddCommand(newPtyHostCommand()) root.AddCommand(newImportCommand(ctx)) root.AddCommand(newProjectCommand(ctx)) root.AddCommand(newSessionCommand(ctx)) diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go index ae7bac4c..d19d540d 100644 --- a/backend/internal/cli/spawn.go +++ b/backend/internal/cli/spawn.go @@ -10,7 +10,6 @@ import ( "github.com/spf13/pflag" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" ) type spawnOptions struct { @@ -101,15 +100,12 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { } // Print a copy-pasteable attach hint for the selected runtime. // On Darwin/Linux: tmux attach-session using the sanitised session name. - // On Windows: zellij attach with the short socket dir prefix. + // On Windows: ConPTY has no user-facing attach CLI; use the AO dashboard. var attach string if runtime.GOOS != "windows" { attach = fmt.Sprintf("tmux attach -t %s", tmux.SessionName(res.Session.ID)) } else { - attach = fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) - if dir := zellij.DefaultSocketDir(); dir != "" { - attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) - } + attach = "Attach from the AO dashboard (ConPTY sessions have no CLI attach command)" } _, err := fmt.Fprintf(out, "attach with: %s\n", attach) return err diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 5d211600..add24730 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -127,7 +127,7 @@ func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlit } // runtimeMessageSender is the narrow part of the concrete runtime needed by -// ao send. zellij.Runtime already implements this via SendMessage. +// ao send. Both tmux.Runtime and conpty.Runtime implement this via SendMessage. type runtimeMessageSender interface { SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 21bd248d..eda8ac1a 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -11,7 +11,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" telemetryadapter "github.com/aoagents/agent-orchestrator/backend/internal/adapters/telemetry" "github.com/aoagents/agent-orchestrator/backend/internal/cdc" "github.com/aoagents/agent-orchestrator/backend/internal/config" @@ -321,7 +321,7 @@ func TestWiring_StartLifecycleThreadsMessengerIntoLCM(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) messenger := &captureMessenger{} - stack := startLifecycle(ctx, store, zellij.New(zellij.Options{}), messenger, nil, nil, log) + stack := startLifecycle(ctx, store, tmux.New(tmux.Options{}), messenger, nil, nil, log) t.Cleanup(stack.Stop) t.Cleanup(cancel) @@ -378,22 +378,3 @@ func TestProjectRepoResolver_ResolvesRegisteredProject(t *testing.T) { } } -// TestDaemonZellijSocketDir_LeavesBudgetForSessionNames guards the fix for the -// zellij "session name must be less than 0 characters" spawn failure: the -// daemon's socket dir must be short enough that a max-length (48-char) session -// name still fits the ~103-byte unix-domain-socket-path budget. zellij's long -// $TMPDIR default (the bug) would fail this. -func TestDaemonZellijSocketDir_LeavesBudgetForSessionNames(t *testing.T) { - dir := zellij.DefaultSocketDir() - if dir == "" { - t.Skip("zellij not used on this platform") - } - const ( - unixSocketPathMax = 103 // sun_path budget zellij enforces on macOS - zellijOverhead = 24 // zellij's version subdir + separators (generous) - maxSessionName = 48 // zellijSessionName's cap - ) - if budget := unixSocketPathMax - len(dir) - zellijOverhead; budget < maxSessionName { - t.Fatalf("zellij socket dir %q too long: %d bytes left for the session name, need >= %d", dir, budget, maxSessionName) - } -} diff --git a/backend/internal/terminal/attachment_integration_test.go b/backend/internal/terminal/attachment_integration_test.go index 452362f6..940a9199 100644 --- a/backend/internal/terminal/attachment_integration_test.go +++ b/backend/internal/terminal/attachment_integration_test.go @@ -6,32 +6,26 @@ import ( "context" "os" "os/exec" - "path/filepath" "strconv" "strings" "testing" "time" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// TestAttachmentStreamsRealZellijPane attaches a real PTY to a real Zellij -// session and asserts output streams back, then that killing the pane stops the -// attachment without a re-attach storm. Skipped when Zellij is unavailable. -func TestAttachmentStreamsRealZellijPane(t *testing.T) { - zellijBin, err := exec.LookPath("zellij") - if err != nil { - t.Skip("zellij unavailable") +// TestAttachmentStreamsRealTmuxPane attaches a real PTY to a real tmux session +// and asserts output streams back, then that killing the session stops the +// attachment without a re-attach storm. Skipped when tmux is unavailable. +func TestAttachmentStreamsRealTmuxPane(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux unavailable") } name := "ao-term-it-" + strconv.Itoa(os.Getpid()) - socketDir := filepath.Join("/tmp", name+"-socket") - if err := os.MkdirAll(socketDir, 0o755); err != nil { - t.Fatalf("mkdir socket dir: %v", err) - } - rt := zellij.New(zellij.Options{Binary: zellijBin, SocketDir: socketDir, ConfigDir: t.TempDir(), Timeout: 5 * time.Second}) + rt := tmux.New(tmux.Options{Timeout: 10 * time.Second}) handle, err := rt.Create(context.Background(), ports.RuntimeConfig{ SessionID: domain.SessionID(name), WorkspacePath: t.TempDir(), @@ -52,14 +46,6 @@ func TestAttachmentStreamsRealZellijPane(t *testing.T) { eventually(t, 3*time.Second, func() bool { return a.write([]byte("echo AO_MARKER_42\n")) == nil }) eventually(t, 5*time.Second, func() bool { return strings.Contains(got.string(), "AO_MARKER_42") }) - // A fresh attach must carry zellij's alt-screen init handshake. Mouse - // reporting is deliberately disabled for AO's embedded client, so this test - // should not require SGR mouse mode. - eventually(t, 5*time.Second, func() bool { - out := got.string() - return strings.Contains(out, "\x1b[?1049h") - }) - // Kill the session: the attachment must observe it as gone and not re-attach. if err := rt.Destroy(context.Background(), handle); err != nil { t.Fatalf("Destroy: %v", err) @@ -70,21 +56,14 @@ func TestAttachmentStreamsRealZellijPane(t *testing.T) { // TestAttachmentReattachAdoptsNewSize is the end-to-end regression for the // stale-size desync: client A holds the session at one grid, detaches, and // client B immediately attaches at a different grid (the frontend's -// remount/reconnect flow). B's zellij client must adopt B's size — the inner -// pane's tty must report it — not stay laid out for A's. This is where the -// spawn-at-size + explicit-WINCH + SIGTERM-detach fixes meet a real zellij. +// remount/reconnect flow). B's tmux client must adopt B's size, not A's. func TestAttachmentReattachAdoptsNewSize(t *testing.T) { - zellijBin, err := exec.LookPath("zellij") - if err != nil { - t.Skip("zellij unavailable") + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux unavailable") } name := "ao-term-size-it-" + strconv.Itoa(os.Getpid()) - socketDir := filepath.Join("/tmp", name+"-socket") - if err := os.MkdirAll(socketDir, 0o755); err != nil { - t.Fatalf("mkdir socket dir: %v", err) - } - rt := zellij.New(zellij.Options{Binary: zellijBin, SocketDir: socketDir, ConfigDir: t.TempDir(), Timeout: 5 * time.Second}) + rt := tmux.New(tmux.Options{Timeout: 10 * time.Second}) handle, err := rt.Create(context.Background(), ports.RuntimeConfig{ SessionID: domain.SessionID(name), WorkspacePath: t.TempDir(), @@ -111,22 +90,20 @@ func TestAttachmentReattachAdoptsNewSize(t *testing.T) { a, _, openedA, cancelA := attachAt(37, 115) select { case <-openedA: - case <-time.After(5 * time.Second): + case <-time.After(10 * time.Second): t.Fatal("client A did not attach") } a.close() cancelA() - // Client B re-attaches immediately at 148x40 — no settle gap, same as the - // frontend reconnecting. The inner pane must see B's grid (zellij chrome - // shaves a couple rows/cols, so assert the reported cols land near 148 and - // far from 115). + // Client B re-attaches immediately at 148x40. The inner pane must see B's + // grid (tmux may shave a row/col; assert cols land near 148 and far from 115). b, gotB, openedB, cancelB := attachAt(40, 148) defer cancelB() defer b.close() select { case <-openedB: - case <-time.After(5 * time.Second): + case <-time.After(10 * time.Second): t.Fatal("client B did not attach") } @@ -145,6 +122,6 @@ func TestAttachmentReattachAdoptsNewSize(t *testing.T) { if err != nil { return false } - return cols > 130 // B's 148 minus zellij chrome; a stale A-layout reports ≤115 + return cols > 130 // B's 148 minus any tmux chrome; a stale A-layout reports <=115 }) } From 3b7ff9c1a39837411eb040fe9bb317db316e3f94 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 24 Jun 2026 00:42:21 +0530 Subject: [PATCH 20/60] docs(daemon): correct terminal-runtime comment to conpty on Windows Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/task-b6-report.md | 107 + .superpowers/sdd/task-b6-review-package.txt | 3502 +++++++++++++++++++ backend/internal/daemon/daemon.go | 4 +- 3 files changed, 3611 insertions(+), 2 deletions(-) create mode 100644 .superpowers/sdd/task-b6-report.md create mode 100644 .superpowers/sdd/task-b6-review-package.txt diff --git a/.superpowers/sdd/task-b6-report.md b/.superpowers/sdd/task-b6-report.md new file mode 100644 index 00000000..36d5dd97 --- /dev/null +++ b/.superpowers/sdd/task-b6-report.md @@ -0,0 +1,107 @@ +# Task B6 Report: select conpty on Windows, register pty-host, delete zellij + +## Status: DONE + +Commit: `6a18394` on branch `migrate-zellij-to-tmux-conpty`. + +--- + +## What was done + +### Step 1: runtimeselect switched to conpty on Windows + +`backend/internal/adapters/runtime/runtimeselect/runtimeselect.go`: +- Package doc updated to "tmux on Darwin/Linux, conpty (ConPTY) on Windows". +- Imports: dropped `zellij`, added `conpty`. +- Windows branch: removed `DefaultSocketDir` + `MkdirAll` + warn block; returns `conpty.New(conpty.Options{})`. +- `log` param renamed to `_` (signature kept stable for all callers; `_ *slog.Logger` signature preserved). +- Compile-time assertion changed from `(*zellij.Runtime)(nil)` to `(*conpty.Runtime)(nil)`. tmux assertion unchanged. + +### Step 2: `ao pty-host` subcommand registered + +New file `backend/internal/cli/ptyhost.go`: +- `newPtyHostCommand()` returns a hidden cobra command with `DisableFlagParsing: true` so agent shell args with leading dashes are not consumed by cobra. +- `RunE` calls `conpty.RunHost(args, os.Stdout)` and calls `os.Exit(code)` on non-zero return (matching the pattern of how launch handles its subprocess exit codes). +- Wired in `root.go` immediately after `newLaunchCommand(ctx)`. + +### Step 3: Zellij package deleted + +`git rm -r backend/internal/adapters/runtime/zellij` (6 files removed: commands.go, process\_other.go, process\_windows.go, zellij.go, zellij\_integration\_test.go, zellij\_test.go). + +Every reference fixed: + +**`cli/doctor.go`**: +- Removed `zellij` import. +- Replaced `checkTerminalRuntime`/`checkZellij` with a version that returns a static `doctorPass` on Windows ("ConPTY (built-in): no external terminal multiplexer required on Windows") and calls `checkTmux` on non-Windows. `checkTmux` function body unchanged. + +**`cli/spawn.go`**: +- Removed `zellij` import. +- Windows attach hint changed from `zellij attach + ZELLIJ_SOCKET_DIR` to "Attach from the AO dashboard (ConPTY sessions have no CLI attach command)". +- Non-Windows tmux hint unchanged. + +**`daemon/wiring_test.go`**: +- Replaced `zellij` import with `tmux`. +- `TestWiring_StartLifecycleThreadsMessengerIntoLCM`: `zellij.New(zellij.Options{})` replaced with `tmux.New(tmux.Options{})`. +- `TestDaemonZellijSocketDir_LeavesBudgetForSessionNames` deleted entirely (tested a helper that no longer exists). + +**`daemon/lifecycle_wiring.go`**: +- Stale comment "zellij.Runtime already implements this via SendMessage" updated to "Both tmux.Runtime and conpty.Runtime implement this via SendMessage". No import was present. + +### Step 4: Integration test re-pointed at tmux + +`backend/internal/terminal/attachment_integration_test.go` (was zellij-backed, now tmux-backed): +- Build tag `//go:build !windows` preserved. +- `zellij` import replaced with `tmux`. +- `TestAttachmentStreamsRealZellijPane` renamed `TestAttachmentStreamsRealTmuxPane`: creates a tmux session, writes via `rt.Create`, attaches via `rt.Attach` (through `newAttachment`), echoes a marker, verifies it appears in the stream, destroys, verifies `isExited()`. Session killed in `t.Cleanup`. +- `TestAttachmentReattachAdoptsNewSize` kept the same name and intent: client A at 37x115, detaches; client B at 40x148 re-attaches, issues `stty size`, asserts cols > 130. Timeout bumped from 5s to 10s for tmux startup. Session killed in `t.Cleanup`. +- Both tests skip if `tmux` is not in PATH (skip guard kept). + +--- + +## Verification outputs + +### grep -rn "runtime/zellij" . (from backend/) + +``` +(empty - exit code 1, no matches) +``` + +### go build ./... + +``` +Go build: Success +``` + +### GOOS=windows go build ./... + +``` +Go build: Success +``` + +### GOOS=linux go build ./... + +``` +Go build: Success +``` + +### go test -race ./... + +``` +Go test: 1607 passed in 77 packages +``` + +The tmux-backed integration tests ran (not skipped) and passed: +- `TestAttachmentStreamsRealTmuxPane` +- `TestAttachmentReattachAdoptsNewSize` + +### go vet ./... + +``` +Go vet: No issues found +``` + +--- + +## Concerns + +None. All changes are surgical; the union interface and all consumer interfaces are intact. `internal/agentlaunch` is still imported by `cli/launch.go` and was not touched. The remaining "zellij" strings in the codebase are comments only (in tmux.go, doc.go, conpty files, ports, agent adapters) and do not affect the build or runtime behavior. diff --git a/.superpowers/sdd/task-b6-review-package.txt b/.superpowers/sdd/task-b6-review-package.txt new file mode 100644 index 00000000..e8a28512 --- /dev/null +++ b/.superpowers/sdd/task-b6-review-package.txt @@ -0,0 +1,3502 @@ +=== COMMITS === +6a18394 feat(runtime): select conpty on Windows, register pty-host subcommand... + +=== STAT === +.../runtime/runtimeselect/runtimeselect.go | 26 +- + .../internal/adapters/runtime/zellij/commands.go | 405 --------- + .../adapters/runtime/zellij/process_other.go | 18 - + .../adapters/runtime/zellij/process_windows.go | 79 -- + backend/internal/adapters/runtime/zellij/zellij.go | 964 --------------------- + .../runtime/zellij/zellij_integration_test.go | 165 ---- + .../adapters/runtime/zellij/zellij_test.go | 734 ---------------- + backend/internal/cli/doctor.go | 30 +- + backend/internal/cli/ptyhost.go | 29 + + backend/internal/cli/root.go | 1 + + backend/internal/cli/spawn.go | 8 +- + backend/internal/daemon/lifecycle_wiring.go | 2 +- + backend/internal/daemon/wiring_test.go | 23 +- + .../terminal/attachment_integration_test.go | 57 +- + 14 files changed, 68 insertions(+), 2473 deletions(-) +=== DIFF === +.../runtime/runtimeselect/runtimeselect.go | 26 +- + .../internal/adapters/runtime/zellij/commands.go | 405 --------- + .../adapters/runtime/zellij/process_other.go | 18 - + .../adapters/runtime/zellij/process_windows.go | 79 -- + backend/internal/adapters/runtime/zellij/zellij.go | 964 --------------------- + .../runtime/zellij/zellij_integration_test.go | 165 ---- + .../adapters/runtime/zellij/zellij_test.go | 734 ---------------- + backend/internal/cli/doctor.go | 30 +- + backend/internal/cli/ptyhost.go | 29 + + backend/internal/cli/root.go | 1 + + backend/internal/cli/spawn.go | 8 +- + backend/internal/daemon/lifecycle_wiring.go | 2 +- + backend/internal/daemon/wiring_test.go | 23 +- + .../terminal/attachment_integration_test.go | 57 +- + 14 files changed, 68 insertions(+), 2473 deletions(-) + +diff --git a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go +index 8e486db..3092590 100644 +--- a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go ++++ b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go +@@ -1,47 +1,37 @@ + // Package runtimeselect picks the correct runtime backend by platform: +-// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). ++// tmux on Darwin/Linux, conpty (ConPTY) on Windows. + package runtimeselect + + import ( + "context" + "log/slog" +- "os" + "runtime" + ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" +- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + ) + +-// Runtime is the union interface that both tmux and zellij satisfy. ++// Runtime is the union interface that both tmux and conpty satisfy. + // It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods + // the daemon wires directly, including ports.Attacher (Attach) so the terminal + // layer can open a Stream against the selected runtime. + type Runtime interface { + ports.Runtime // Create, Destroy, IsAlive + ports.Attacher + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error + GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) + } + + // Compile-time assertions: both adapters must implement the union interface. + var _ Runtime = (*tmux.Runtime)(nil) +-var _ Runtime = (*zellij.Runtime)(nil) ++var _ Runtime = (*conpty.Runtime)(nil) + +-// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. +-// log is used only for the Windows zellij socket-dir warning; nil is safe. +-func New(log *slog.Logger) Runtime { ++// New returns the per-platform runtime: tmux on Darwin/Linux, conpty on Windows. ++// log is accepted for signature stability with callers but is currently unused. ++func New(_ *slog.Logger) Runtime { + if runtime.GOOS != "windows" { + return tmux.New(tmux.Options{}) + } +- // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. +- dir := zellij.DefaultSocketDir() +- if dir != "" { +- if err := os.MkdirAll(dir, 0o700); err != nil { +- if log != nil { +- log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) +- } +- } +- } +- return zellij.New(zellij.Options{SocketDir: dir}) ++ return conpty.New(conpty.Options{}) + } +diff --git a/backend/internal/adapters/runtime/zellij/commands.go b/backend/internal/adapters/runtime/zellij/commands.go +deleted file mode 100644 +index b410bd5..0000000 +--- a/backend/internal/adapters/runtime/zellij/commands.go ++++ /dev/null +@@ -1,405 +0,0 @@ +-package zellij +- +-import ( +- "encoding/base64" +- "encoding/binary" +- "fmt" +- "runtime" +- "sort" +- "strconv" +- "strings" +- "unicode/utf16" +- +- "github.com/aoagents/agent-orchestrator/backend/internal/ports" +-) +- +-const ( +- agentPaneName = "agent" +- defaultChunkBytes = 16 * 1024 +-) +- +-func versionArgs() []string { +- return []string{"--version"} +-} +- +-func createSessionArgs(id, layoutPath string) []string { +- clientOptions := embeddedClientOptions() +- args := make([]string, 0, 6+len(clientOptions)+6) +- args = append(args, +- "attach", "--create-background", id, +- "options", +- "--default-layout", layoutPath, +- ) +- args = append(args, clientOptions...) +- args = append(args, +- "--session-serialization", "false", +- "--show-startup-tips", "false", +- "--show-release-notes", "false", +- ) +- return args +-} +- +-func listPanesArgs(id string) []string { +- return []string{"--session", id, "action", "list-panes", "--all", "--json"} +-} +- +-func pasteArgs(id, paneID, chunk string) []string { +- if runtime.GOOS == "windows" { +- return []string{"--session", id, "action", "write-chars", "--pane-id", paneID, chunk} +- } +- return []string{"--session", id, "action", "paste", "--pane-id", paneID, chunk} +-} +- +-func sendEnterArgs(id, paneID string) []string { +- return []string{"--session", id, "action", "send-keys", "--pane-id", paneID, "Enter"} +-} +- +-func dumpScreenArgs(id, paneID string) []string { +- return []string{"--session", id, "action", "dump-screen", "--pane-id", paneID, "--full"} +-} +- +-func listSessionsArgs() []string { +- return []string{"list-sessions", "--no-formatting"} +-} +- +-// deleteSessionArgs builds the teardown command. `delete-session --force` +-// kills a running session AND removes its serialized resurrection state in one +-// step. Plain `kill-session` is not enough: zellij can keep the session in its +-// global resurrection cache as "(EXITED - attach to resurrect)", and any later +-// `zellij attach ` (e.g. the terminal mux re-opening a pane) would resurrect +-// it — re-running the agent command for a session the daemon already destroyed. +-func deleteSessionArgs(id string) []string { +- return []string{"delete-session", "--force", id} +-} +- +-func attachArgs(id string) []string { +- clientOptions := embeddedClientOptions() +- args := make([]string, 0, 3+len(clientOptions)) +- args = append(args, +- "attach", id, +- "options", +- ) +- args = append(args, clientOptions...) +- return args +-} +- +-func embeddedClientOptions() []string { +- return []string{ +- "--pane-frames", "false", +- // The dashboard terminal disables xterm local scrollback and lets the +- // embedded zellij client own scrollback. Keep mouse mode on so wheel +- // reports reach zellij, while leaving richer pointer behaviors off. +- "--mouse-mode", "true", +- "--advanced-mouse-actions", "false", +- "--mouse-hover-effects", "false", +- "--focus-follows-mouse", "false", +- "--mouse-click-through", "false", +- "--support-kitty-keyboard-protocol", "false", +- } +-} +- +-func handleIDValue(sessionID, paneID string) string { +- return sessionID + "/" + paneID +-} +- +-func terminalPaneID(id int) string { +- return fmt.Sprintf("terminal_%d", id) +-} +- +-func buildLayout(cfg ports.RuntimeConfig, shellPath string) string { +- if runtime.GOOS == "windows" { +- return directLayoutString(cfg.WorkspacePath, cfg.Argv) +- } +- spec := shellLaunchSpecFor(shellPath) +- shellCommand := shellLaunchCommand(cfg, spec) +- return layoutString(cfg.WorkspacePath, shellPath, spec.args, shellCommand) +-} +- +-// windowsLaunchArgv returns the argv zellij executes on Windows to start the +-// agent. The trampoline reads the launch spec from AO_LAUNCH_SPEC, so KDL +-// args quoting cannot mangle codex's `--config key=value` flags. +-func windowsLaunchArgv(launcherBinary string) []string { +- command := launcherBinary +- if command == "" { +- command = "ao" +- } +- return []string{command, "launch"} +-} +- +-func windowsFallbackShellArgv(shellPath string) []string { +- if strings.TrimSpace(shellPath) == "" { +- shellPath = "powershell.exe" +- } +- base := strings.ToLower(filepathBase(shellPath)) +- if strings.Contains(base, "cmd") { +- return []string{shellPath, "/D", "/Q", "/K"} +- } +- if strings.Contains(base, "powershell") || strings.Contains(base, "pwsh") { +- return []string{shellPath, "-NoLogo", "-NoProfile", "-NoExit"} +- } +- if strings.Contains(base, "sh") { +- return []string{shellPath, "-i"} +- } +- return []string{shellPath} +-} +- +-// directLayoutString builds a layout that runs argv[0] with argv[1:] as zellij +-// `args`, with no intermediate shell. Used on Windows where wrapping the agent +-// in powershell/cmd quoting is unsound for arbitrary argv (e.g. codex's +-// `--config key="value with spaces"`). +-func directLayoutString(workspacePath string, argv []string) string { +- command := "" +- args := []string{} +- if len(argv) > 0 { +- command = argv[0] +- args = argv[1:] +- } +- +- var b strings.Builder +- b.WriteString("layout {\n") +- b.WriteString(" cwd ") +- b.WriteString(kdlQuote(workspacePath)) +- b.WriteString("\n") +- b.WriteString(" pane command=") +- b.WriteString(kdlQuote(command)) +- b.WriteString(" name=") +- b.WriteString(kdlQuote(agentPaneName)) +- b.WriteString(" borderless=true {\n") +- if len(args) > 0 { +- b.WriteString(" args ") +- b.WriteString(kdlJoin(args)) +- b.WriteString("\n") +- } +- b.WriteString(" }\n") +- b.WriteString("}\n") +- return b.String() +-} +- +-type shellLaunchSpec struct { +- args []string +-} +- +-func shellLaunchSpecFor(shellPath string) shellLaunchSpec { +- base := strings.ToLower(filepathBase(shellPath)) +- if strings.Contains(base, "cmd") { +- return shellLaunchSpec{args: []string{"/D", "/S", "/K"}} +- } +- if strings.Contains(base, "powershell") || strings.Contains(base, "pwsh") { +- return shellLaunchSpec{args: []string{"-NoLogo", "-NoProfile", "-NoExit", "-EncodedCommand"}} +- } +- return shellLaunchSpec{args: []string{"-lc"}} +-} +- +-func layoutString(workspacePath, shellPath string, shellArgs []string, shellCommand string) string { +- return "layout {\n" + +- " cwd " + kdlQuote(workspacePath) + "\n" + +- " pane command=" + kdlQuote(shellPath) + " name=" + kdlQuote(agentPaneName) + " borderless=true {\n" + +- " args " + kdlJoin(shellArgs) + " " + kdlQuote(shellCommand) + "\n" + +- " }\n" + +- "}\n" +-} +- +-func shellLaunchCommand(cfg ports.RuntimeConfig, spec shellLaunchSpec) string { +- if len(spec.args) > 0 && spec.args[0] == "-NoLogo" { +- return wrapLaunchCommandPowerShell(cfg) +- } +- if len(spec.args) > 0 && spec.args[0] == "/D" { +- return wrapLaunchCommandCmd(cfg) +- } +- return wrapLaunchCommandUnix(cfg) +-} +- +-func wrapLaunchCommandUnix(cfg ports.RuntimeConfig) string { +- path := cfg.Env["PATH"] +- if path == "" { +- path = getenv("PATH") +- } +- +- var b strings.Builder +- for _, key := range sortedKeys(cfg.Env) { +- if key == "PATH" { +- continue +- } +- b.WriteString("export ") +- b.WriteString(key) +- b.WriteString("=") +- b.WriteString(shellQuote(cfg.Env[key])) +- b.WriteString("; ") +- } +- if path != "" { +- b.WriteString("export PATH=") +- b.WriteString(shellQuote(path)) +- b.WriteString("; ") +- } +- b.WriteString(quoteArgvUnix(cfg.Argv)) +- return b.String() +-} +- +-func wrapLaunchCommandPowerShell(cfg ports.RuntimeConfig) string { +- path := cfg.Env["PATH"] +- if path == "" { +- path = getenv("PATH") +- } +- +- var b strings.Builder +- for _, key := range sortedKeys(cfg.Env) { +- if key == "PATH" { +- continue +- } +- b.WriteString("$env:") +- b.WriteString(key) +- b.WriteString(" = ") +- b.WriteString(psQuote(cfg.Env[key])) +- b.WriteString("; ") +- } +- if path != "" { +- b.WriteString("$env:PATH = ") +- b.WriteString(psQuote(path)) +- b.WriteString("; ") +- } +- b.WriteString(quoteArgvPowerShell(cfg.Argv)) +- return powerShellEncodedCommand(b.String()) +-} +- +-// powerShellEncodedCommand returns the base64'd UTF-16-LE form of script, +-// suitable for `powershell.exe -EncodedCommand`. zellij's KDL `args` quoting +-// is not robust enough to round-trip arbitrary PowerShell script text through +-// a plain `-Command` argv slot, so we hand PowerShell a single opaque base64 +-// blob instead. +-func powerShellEncodedCommand(script string) string { +- words := utf16.Encode([]rune(script)) +- buf := make([]byte, len(words)*2) +- for i, word := range words { +- binary.LittleEndian.PutUint16(buf[i*2:], word) +- } +- return base64.StdEncoding.EncodeToString(buf) +-} +- +-func wrapLaunchCommandCmd(cfg ports.RuntimeConfig) string { +- path := cfg.Env["PATH"] +- if path == "" { +- path = getenv("PATH") +- } +- +- var b strings.Builder +- for _, key := range sortedKeys(cfg.Env) { +- if key == "PATH" { +- continue +- } +- b.WriteString("set \"") +- b.WriteString(key) +- b.WriteString("=") +- b.WriteString(cmdQuote(cfg.Env[key])) +- b.WriteString("\" && ") +- } +- if path != "" { +- b.WriteString("set \"PATH=") +- b.WriteString(cmdQuote(path)) +- b.WriteString("\" && ") +- } +- b.WriteString(quoteArgvCmd(cfg.Argv)) +- return b.String() +-} +- +-func validateEnvKeys(env map[string]string) error { +- for key := range env { +- if !validEnvKey(key) { +- return fmt.Errorf("zellij runtime: invalid env key %q", key) +- } +- } +- return nil +-} +- +-func validEnvKey(key string) bool { +- if key == "" { +- return false +- } +- for i, r := range key { +- if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { +- continue +- } +- if i > 0 && r >= '0' && r <= '9' { +- continue +- } +- return false +- } +- return true +-} +- +-func sortedKeys(m map[string]string) []string { +- keys := make([]string, 0, len(m)) +- for k := range m { +- keys = append(keys, k) +- } +- sort.Strings(keys) +- return keys +-} +- +-func shellQuote(s string) string { +- return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +-} +- +-func psQuote(s string) string { +- return "'" + strings.ReplaceAll(s, "'", "''") + "'" +-} +- +-func cmdQuote(s string) string { +- return strings.ReplaceAll(s, "\"", "\"\"") +-} +- +-// quoteArgvUnix renders argv as a POSIX-shell command, single-quoting each +-// argument so a value with spaces stays one word under `sh -lc`. +-func quoteArgvUnix(argv []string) string { +- parts := make([]string, len(argv)) +- for i, a := range argv { +- parts[i] = shellQuote(a) +- } +- return strings.Join(parts, " ") +-} +- +-// quoteArgvPowerShell renders argv for `powershell -Command`. The call operator +-// `&` is required so a quoted first token is invoked as a command rather than +-// echoed as a string literal. +-func quoteArgvPowerShell(argv []string) string { +- if len(argv) == 0 { +- return "" +- } +- parts := make([]string, len(argv)) +- for i, a := range argv { +- parts[i] = psQuote(a) +- } +- return "& " + strings.Join(parts, " ") +-} +- +-// quoteArgvCmd renders argv for cmd.exe, wrapping each argument in double quotes +-// (doubling any embedded quote) so spaces don't split a single argument. +-func quoteArgvCmd(argv []string) string { +- parts := make([]string, len(argv)) +- for i, a := range argv { +- parts[i] = "\"" + strings.ReplaceAll(a, "\"", "\"\"") + "\"" +- } +- return strings.Join(parts, " ") +-} +- +-func kdlQuote(s string) string { +- return strconv.Quote(s) +-} +- +-func kdlJoin(args []string) string { +- parts := make([]string, 0, len(args)) +- for _, arg := range args { +- parts = append(parts, kdlQuote(arg)) +- } +- return strings.Join(parts, " ") +-} +- +-func filepathBase(path string) string { +- if path == "" { +- return "" +- } +- i := strings.LastIndexAny(path, `/\`) +- if i < 0 { +- return path +- } +- return path[i+1:] +-} +diff --git a/backend/internal/adapters/runtime/zellij/process_other.go b/backend/internal/adapters/runtime/zellij/process_other.go +deleted file mode 100644 +index 69e955f..0000000 +--- a/backend/internal/adapters/runtime/zellij/process_other.go ++++ /dev/null +@@ -1,18 +0,0 @@ +-//go:build !windows +- +-package zellij +- +-import ( +- "errors" +- "os/exec" +-) +- +-// hideWindow is a no-op off Windows: only Windows pops a console window. +-func hideWindow(*exec.Cmd) {} +- +-// startBackgroundProcess is a stub: the fire-and-forget path is only used by +-// the Windows zellij codepath. Non-Windows builds create sessions +-// synchronously via runner.Run. +-func startBackgroundProcess(env []string, name string, args ...string) error { +- return errors.New("zellij runtime: background spawn is windows-only") +-} +diff --git a/backend/internal/adapters/runtime/zellij/process_windows.go b/backend/internal/adapters/runtime/zellij/process_windows.go +deleted file mode 100644 +index 5130196..0000000 +--- a/backend/internal/adapters/runtime/zellij/process_windows.go ++++ /dev/null +@@ -1,79 +0,0 @@ +-//go:build windows +- +-package zellij +- +-import ( +- "os/exec" +- "strings" +- "syscall" +- +- "golang.org/x/sys/windows" +-) +- +-// hideWindow suppresses the console window for a console-subsystem child so +-// frequent zellij calls (e.g. the reaper's list-sessions) don't flash on Windows. +-func hideWindow(cmd *exec.Cmd) { +- cmd.SysProcAttr = &syscall.SysProcAttr{ +- HideWindow: true, +- CreationFlags: windows.CREATE_NO_WINDOW, +- } +-} +- +-func startBackgroundProcess(env []string, name string, args ...string) error { +- script := "Start-Process -FilePath " + psQuote(name) + " -ArgumentList " + psQuote(windowsCommandLine(args)) + " -WindowStyle Hidden" +- cmd := exec.Command("powershell.exe", "-NoLogo", "-NoProfile", "-EncodedCommand", powerShellEncodedCommand(script)) +- cmd.Env = env +- cmd.SysProcAttr = &syscall.SysProcAttr{ +- // CREATE_NO_WINDOW, not CREATE_NEW_CONSOLE: the latter creates a console +- // then hides it, which flashed a window. +- CreationFlags: windows.CREATE_NO_WINDOW, +- HideWindow: true, +- } +- if err := cmd.Start(); err != nil { +- return err +- } +- go func() { _ = cmd.Wait() }() +- return nil +-} +- +-func windowsCommandLine(args []string) string { +- quoted := make([]string, len(args)) +- for i, arg := range args { +- quoted[i] = windowsQuoteArg(arg) +- } +- return strings.Join(quoted, " ") +-} +- +-func windowsQuoteArg(arg string) string { +- if arg == "" { +- return `""` +- } +- if !strings.ContainsAny(arg, " \t\"") { +- return arg +- } +- +- var b strings.Builder +- b.WriteByte('"') +- backslashes := 0 +- for _, r := range arg { +- switch r { +- case '\\': +- backslashes++ +- case '"': +- b.WriteString(strings.Repeat(`\`, backslashes*2+1)) +- b.WriteRune(r) +- backslashes = 0 +- default: +- if backslashes > 0 { +- b.WriteString(strings.Repeat(`\`, backslashes)) +- backslashes = 0 +- } +- b.WriteRune(r) +- } +- } +- if backslashes > 0 { +- b.WriteString(strings.Repeat(`\`, backslashes*2)) +- } +- b.WriteByte('"') +- return b.String() +-} +diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go +deleted file mode 100644 +index d7a8451..0000000 +--- a/backend/internal/adapters/runtime/zellij/zellij.go ++++ /dev/null +@@ -1,964 +0,0 @@ +-// 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/adapters/runtime/ptyexec" +- "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) +-var _ ports.Attacher = (*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")) +-} +- +-// Attach opens a fresh attach Stream by spawning the zellij attach client on a +-// local PTY, sized rows x cols from birth when known. On Windows the per-session +-// env block (ZELLIJ_SOCKET_DIR) is applied to the child. ctx cancellation closes +-// the PTY. +-func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { +- argv, env, err := r.attachCommand(handle) +- if err != nil { +- return nil, err +- } +- return ptyexec.Spawn(ctx, argv, env, rows, cols) +-} +- +-// 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 } +diff --git a/backend/internal/adapters/runtime/zellij/zellij_integration_test.go b/backend/internal/adapters/runtime/zellij/zellij_integration_test.go +deleted file mode 100644 +index ac6a33e..0000000 +--- a/backend/internal/adapters/runtime/zellij/zellij_integration_test.go ++++ /dev/null +@@ -1,165 +0,0 @@ +-package zellij +- +-import ( +- "context" +- "os" +- "os/exec" +- "path/filepath" +- "runtime" +- "strings" +- "testing" +- "time" +- +- "github.com/aoagents/agent-orchestrator/backend/internal/ports" +-) +- +-func TestRuntimeIntegration(t *testing.T) { +- if _, err := exec.LookPath("zellij"); err != nil { +- t.Skip("zellij unavailable") +- } +- +- ctx := context.Background() +- id := "ao_itest_zj" +- socketDir := tempSocketDir(t, "ao-zj-itest-") +- configDir := t.TempDir() +- opts := Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: configDir} +- if runtime.GOOS == "windows" { +- opts.Timeout = 30 * time.Second +- opts.LauncherBinary = buildAOForIntegration(t) +- opts.Shell = "cmd.exe" +- } +- r := New(opts) +- _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) +- argv := []string{"sh", "-lc", "printf ready-$AO_SESSION_ID\\n; exec sh -i"} +- sendCommand := "echo hello-from-zellij" +- if runtime.GOOS == "windows" { +- argv = []string{"cmd.exe", "/D", "/Q", "/K", "echo ready-%AO_SESSION_ID%"} +- } +- +- h, err := r.Create(ctx, ports.RuntimeConfig{ +- SessionID: "ao_itest_zj", +- WorkspacePath: t.TempDir(), +- Argv: argv, +- Env: map[string]string{"AO_SESSION_ID": id}, +- }) +- if err != nil { +- t.Fatalf("Create: %v", err) +- } +- defer r.Destroy(ctx, h) +- +- alive, err := r.IsAlive(ctx, h) +- if err != nil { +- t.Fatalf("IsAlive: %v", err) +- } +- if !alive { +- t.Fatal("alive = false, want true") +- } +- prefixAlive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: "ao_itest"}) +- if err != nil { +- t.Fatalf("IsAlive prefix: %v", err) +- } +- if prefixAlive { +- t.Fatal("prefix handle reported alive; zellij session matching is not exact") +- } +- +- out := waitForRuntimeOutput(t, r, h, "ready-") +- if !strings.Contains(out, "ready-") { +- t.Fatalf("output = %q, want ready output", out) +- } +- if err := r.SendMessage(ctx, h, sendCommand); err != nil { +- t.Fatalf("SendMessage: %v", err) +- } +- out = waitForRuntimeOutput(t, r, h, "hello-from-zellij") +- if !strings.Contains(out, "hello-from-zellij") { +- t.Fatalf("output = %q, want sent command output", out) +- } +- +- if err := r.Destroy(ctx, h); err != nil { +- t.Fatalf("Destroy: %v", err) +- } +- alive, err = r.IsAlive(ctx, h) +- if err != nil { +- t.Fatalf("IsAlive after destroy: %v", err) +- } +- if alive { +- t.Fatal("alive after destroy = true, want false") +- } +-} +- +-func waitForRuntimeOutput(t *testing.T, r *Runtime, h ports.RuntimeHandle, want string) string { +- t.Helper() +- deadline := time.Now().Add(3 * time.Second) +- var out string +- for time.Now().Before(deadline) { +- var err error +- out, err = r.GetOutput(context.Background(), h, 30) +- if err != nil { +- t.Fatalf("GetOutput: %v", err) +- } +- if strings.Contains(out, want) { +- return out +- } +- time.Sleep(100 * time.Millisecond) +- } +- return out +-} +- +-func buildAOForIntegration(t *testing.T) string { +- t.Helper() +- out := filepath.Join(t.TempDir(), "ao.exe") +- cmd := exec.Command("go", "build", "-o", out, "./cmd/ao") +- cmd.Dir = filepath.Clean(filepath.Join("..", "..", "..", "..")) +- if raw, err := cmd.CombinedOutput(); err != nil { +- t.Fatalf("build ao test launcher: %v: %s", err, strings.TrimSpace(string(raw))) +- } +- return out +-} +- +-func tempSocketDir(t *testing.T, pattern string) string { +- t.Helper() +- parent := os.TempDir() +- if runtime.GOOS != "windows" { +- parent = "/tmp" +- } +- socketDir, err := os.MkdirTemp(parent, pattern) +- if err != nil { +- t.Fatalf("mkdir socket dir: %v", err) +- } +- t.Cleanup(func() { _ = os.RemoveAll(socketDir) }) +- return socketDir +-} +- +-func TestRuntimeIntegrationUsesExactSessionParsing(t *testing.T) { +- if _, err := exec.LookPath("zellij"); err != nil { +- t.Skip("zellij unavailable") +- } +- if runtime.GOOS == "windows" { +- t.Skip("exact session parsing is covered by TestRuntimeIntegration on Windows") +- } +- +- ctx := context.Background() +- socketDir := tempSocketDir(t, "ao-zj-exact-itest-") +- r := New(Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: t.TempDir()}) +- longID := "ao_zj_exact_long" +- prefixID := "ao_zj_exact" +- _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) +- _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID}) +- +- h, err := r.Create(ctx, ports.RuntimeConfig{ +- SessionID: "ao_zj_exact_long", +- WorkspacePath: t.TempDir(), +- Argv: []string{"sh", "-lc", "printf ready\\n; exec sh -i"}, +- }) +- if err != nil { +- t.Fatalf("Create: %v", err) +- } +- defer r.Destroy(ctx, h) +- +- alive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID}) +- if err != nil { +- t.Fatalf("IsAlive prefix: %v", err) +- } +- if alive { +- t.Fatal("prefix handle reported alive; zellij session matching is not exact") +- } +-} +diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go +deleted file mode 100644 +index 1a6d6a8..0000000 +--- a/backend/internal/adapters/runtime/zellij/zellij_test.go ++++ /dev/null +@@ -1,734 +0,0 @@ +-package zellij +- +-import ( +- "context" +- "errors" +- "os/exec" +- "reflect" +- "runtime" +- "strings" +- "testing" +- "time" +- +- "github.com/aoagents/agent-orchestrator/backend/internal/domain" +- "github.com/aoagents/agent-orchestrator/backend/internal/ports" +-) +- +-func TestNewDefaultsToPortableShell(t *testing.T) { +- t.Setenv("SHELL", "") +- r := New(Options{}) +- want := "/bin/sh" +- if runtime.GOOS == "windows" { +- want = "powershell.exe" +- } +- if got := r.shell; got != want { +- t.Fatalf("default shell = %q, want %q", got, want) +- } +-} +- +-func TestZellijCommandEnvNormalizesBrowserTerminalColors(t *testing.T) { +- got := zellijCommandEnv( +- []string{"NO_COLOR=1", "TERM=dumb", "COLORTERM=", "KEEP=yes"}, +- []string{"ZELLIJ_SOCKET_DIR=/tmp/zj"}, +- ) +- +- if runtime.GOOS == "windows" { +- if !contains(got, "NO_COLOR=1") { +- t.Fatalf("windows env = %#v, want NO_COLOR preserved", got) +- } +- return +- } +- +- if containsKey(got, "NO_COLOR") { +- t.Fatalf("NO_COLOR survived env normalization: %#v", got) +- } +- for _, want := range []string{"KEEP=yes", "TERM=xterm-256color", "COLORTERM=truecolor", "ZELLIJ_SOCKET_DIR=/tmp/zj"} { +- if !contains(got, want) { +- t.Fatalf("env missing %q in %#v", want, got) +- } +- } +-} +- +-func expectedZellijEnv(socketDir string) []string { +- env := []string{} +- if runtime.GOOS != "windows" { +- env = append(env, "TERM=xterm-256color", "COLORTERM=truecolor") +- } +- if socketDir != "" { +- env = append(env, "ZELLIJ_SOCKET_DIR="+socketDir) +- } +- return env +-} +- +-func expectedAttachEnvPrefix() []string { +- if runtime.GOOS == "windows" { +- return []string{} +- } +- return []string{"env", "-u", "NO_COLOR", "TERM=xterm-256color", "COLORTERM=truecolor"} +-} +- +-func contains(values []string, want string) bool { +- for _, value := range values { +- if value == want { +- return true +- } +- } +- return false +-} +- +-func containsKey(values []string, key string) bool { +- prefix := key + "=" +- for _, value := range values { +- if strings.HasPrefix(value, prefix) { +- return true +- } +- } +- return false +-} +- +-func TestCommandBuilders(t *testing.T) { +- embeddedOptions := []string{ +- "--pane-frames", "false", +- "--mouse-mode", "true", +- "--advanced-mouse-actions", "false", +- "--mouse-hover-effects", "false", +- "--focus-follows-mouse", "false", +- "--mouse-click-through", "false", +- "--support-kitty-keyboard-protocol", "false", +- } +- if got, want := versionArgs(), []string{"--version"}; !reflect.DeepEqual(got, want) { +- t.Fatalf("versionArgs = %#v, want %#v", got, want) +- } +- wantCreate := append([]string{"attach", "--create-background", "sess-1", "options", "--default-layout", "/tmp/layout.kdl"}, embeddedOptions...) +- wantCreate = append(wantCreate, "--session-serialization", "false", "--show-startup-tips", "false", "--show-release-notes", "false") +- if got, want := createSessionArgs("sess-1", "/tmp/layout.kdl"), wantCreate; !reflect.DeepEqual(got, want) { +- t.Fatalf("createSessionArgs = %#v, want %#v", got, want) +- } +- if got, want := listPanesArgs("sess-1"), []string{"--session", "sess-1", "action", "list-panes", "--all", "--json"}; !reflect.DeepEqual(got, want) { +- t.Fatalf("listPanesArgs = %#v, want %#v", got, want) +- } +- pasteAction := "paste" +- if runtime.GOOS == "windows" { +- pasteAction = "write-chars" +- } +- if got, want := pasteArgs("sess-1", "terminal_0", "hello"), []string{"--session", "sess-1", "action", pasteAction, "--pane-id", "terminal_0", "hello"}; !reflect.DeepEqual(got, want) { +- t.Fatalf("pasteArgs = %#v, want %#v", got, want) +- } +- if got, want := dumpScreenArgs("sess-1", "terminal_0"), []string{"--session", "sess-1", "action", "dump-screen", "--pane-id", "terminal_0", "--full"}; !reflect.DeepEqual(got, want) { +- t.Fatalf("dumpScreenArgs = %#v, want %#v", got, want) +- } +- // delete-session --force (not kill-session): teardown must also purge the +- // serialized resurrection state, or a later `zellij attach` re-creates the +- // session — and re-runs its agent — after the daemon destroyed it. +- if got, want := deleteSessionArgs("sess-1"), []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { +- t.Fatalf("deleteSessionArgs = %#v, want %#v", got, want) +- } +- wantAttach := append([]string{"attach", "sess-1", "options"}, embeddedOptions...) +- if got, want := attachArgs("sess-1"), wantAttach; !reflect.DeepEqual(got, want) { +- t.Fatalf("attachArgs = %#v, want %#v", got, want) +- } +-} +- +-func TestZellijSessionNameSanitizesIssueRefs(t *testing.T) { +- got, err := zellijSessionName("repo/issue#42.1") +- if err != nil { +- t.Fatalf("zellijSessionName: %v", err) +- } +- if err := validateSessionID(got); err != nil { +- t.Fatalf("sanitized id %q is invalid: %v", got, err) +- } +- if !strings.HasPrefix(got, "repo-issue-42-1-") { +- t.Fatalf("sanitized id = %q, want readable prefix", got) +- } +- if got == "repo/issue#42.1" { +- t.Fatal("sanitized id still contains raw unsafe characters") +- } +-} +- +-// SessionName must return the exact name Create registers a session under, so +-// callers that print an attach hint (e.g. `ao spawn`) reference the real +-// session. A short, conforming id passes through; a long one is sanitised to a +-// different name — printing the raw id there would send users to a missing +-// session. +-func TestSessionNameMatchesCreateNaming(t *testing.T) { +- short := "myproj-1" +- if got := SessionName(short); got != short { +- t.Fatalf("SessionName(%q) = %q, want it unchanged", short, got) +- } +- +- long := domain.SessionID(strings.Repeat("x", 60) + "-1") +- viaCreate, err := zellijSessionName(long) +- if err != nil { +- t.Fatalf("zellijSessionName: %v", err) +- } +- if got := SessionName(string(long)); got != viaCreate { +- t.Fatalf("SessionName = %q, but Create uses %q", got, viaCreate) +- } +- if SessionName(string(long)) == string(long) { +- t.Fatal("expected a long id to be sanitised to a different name") +- } +-} +- +-func TestValidateSessionAndPaneID(t *testing.T) { +- for _, id := range []string{"sess-1", "S_2", "abc123"} { +- if err := validateSessionID(id); err != nil { +- t.Fatalf("validateSessionID(%q): %v", id, err) +- } +- } +- for _, id := range []string{"", "sess.1", "sess/1", "$(boom)", "with space"} { +- if err := validateSessionID(id); err == nil { +- t.Fatalf("validateSessionID(%q): got nil, want error", id) +- } +- } +- for _, id := range []string{"terminal_0", "terminal_42"} { +- if err := validatePaneID(id); err != nil { +- t.Fatalf("validatePaneID(%q): %v", id, err) +- } +- } +- for _, id := range []string{"", "0", "plugin_0", "terminal_x", "terminal_1/2"} { +- if err := validatePaneID(id); err == nil { +- t.Fatalf("validatePaneID(%q): got nil, want error", id) +- } +- } +-} +- +-func TestHandleID(t *testing.T) { +- session, pane, err := handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7"}) +- if err != nil { +- t.Fatalf("handleID: %v", err) +- } +- if session != "sess-1" || pane != "terminal_7" { +- t.Fatalf("handleID = %q/%q", session, pane) +- } +-} +- +-func TestBuildLayoutExportsEnvAndRunsAgentCommand(t *testing.T) { +- oldGetenv := getenv +- getenv = func(key string) string { +- if key == "PATH" { +- return "/usr/bin:/bin" +- } +- return "" +- } +- defer func() { getenv = oldGetenv }() +- +- got := buildLayout(ports.RuntimeConfig{WorkspacePath: "/tmp/ws", Argv: []string{"ao", "run"}, Env: map[string]string{ +- "AO_SESSION_ID": "sess-1", +- "ODD": "can't", +- "PATH": "/custom/bin:/usr/bin", +- }}, "/bin/zsh") +- if runtime.GOOS == "windows" { +- for _, want := range []string{ +- `cwd "/tmp/ws"`, +- `pane command="ao" name="agent" borderless=true`, +- `args "run"`, +- } { +- if !strings.Contains(got, want) { +- t.Fatalf("direct windows layout missing %q in %q", want, got) +- } +- } +- return +- } +- +- for _, want := range []string{ +- `cwd "/tmp/ws"`, +- `pane command="/bin/zsh" name="agent" borderless=true`, +- "export AO_SESSION_ID='sess-1';", +- "export ODD='can'\\\\''t';", +- "export PATH='/custom/bin:/usr/bin';", +- "'ao' 'run'", +- } { +- if !strings.Contains(got, want) { +- t.Fatalf("layout missing %q in %q", want, got) +- } +- } +- if strings.Contains(got, "exec '/bin/zsh' -i") { +- t.Fatalf("layout kept pane alive after agent exit: %q", got) +- } +-} +- +-func TestBuildLayoutUsesPowerShellLaunchOnWindowsShells(t *testing.T) { +- oldGetenv := getenv +- getenv = func(key string) string { +- if key == "PATH" { +- return `C:\custom\bin` +- } +- return "" +- } +- defer func() { getenv = oldGetenv }() +- +- got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, Argv: []string{"Write-Host", "ready"}, Env: map[string]string{ +- "AO_SESSION_ID": "sess-1", +- }}, `C:\Program Files\PowerShell\7\pwsh.exe`) +- if runtime.GOOS == "windows" { +- for _, want := range []string{ +- `cwd "C:\\ws"`, +- `pane command="Write-Host" name="agent" borderless=true`, +- `args "ready"`, +- } { +- if !strings.Contains(got, want) { +- t.Fatalf("direct windows layout missing %q in %q", want, got) +- } +- } +- return +- } +- +- for _, want := range []string{ +- `pane command="C:\\Program Files\\PowerShell\\7\\pwsh.exe" name="agent" borderless=true`, +- `args "-NoLogo" "-NoProfile" "-NoExit" "-EncodedCommand"`, +- } { +- if !strings.Contains(got, want) { +- t.Fatalf("powershell layout missing %q in %q", want, got) +- } +- } +-} +- +-func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) { +- oldGetenv := getenv +- getenv = func(key string) string { +- return "" +- } +- defer func() { getenv = oldGetenv }() +- +- got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, Argv: []string{"echo", "ready"}, Env: map[string]string{ +- "AO_SESSION_ID": "sess-1", +- }}, `C:\Windows\System32\cmd.exe`) +- if runtime.GOOS == "windows" { +- for _, want := range []string{ +- `cwd "C:\\ws"`, +- `pane command="echo" name="agent" borderless=true`, +- `args "ready"`, +- } { +- if !strings.Contains(got, want) { +- t.Fatalf("direct windows layout missing %q in %q", want, got) +- } +- } +- return +- } +- +- for _, want := range []string{ +- `pane command="C:\\Windows\\System32\\cmd.exe" name="agent" borderless=true`, +- `args "/D" "/S" "/K"`, +- `AO_SESSION_ID=sess-1`, +- `\"echo\" \"ready\"`, +- } { +- if !strings.Contains(got, want) { +- t.Fatalf("cmd layout missing %q in %q", want, got) +- } +- } +-} +- +-func TestCreateRejectsInvalidEnvKeys(t *testing.T) { +- r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh"}) +- r.runner = &fakeRunner{} +- _, err := r.Create(context.Background(), ports.RuntimeConfig{ +- SessionID: "sess-1", +- WorkspacePath: "/tmp/ws", +- Argv: []string{"echo", "ready"}, +- Env: map[string]string{"BAD KEY": "x"}, +- }) +- if err == nil || !strings.Contains(err.Error(), "invalid env key") { +- t.Fatalf("Create err = %v, want invalid env key", err) +- } +-} +- +-func TestCreateStartsSessionAndDiscoversPane(t *testing.T) { +- panesOut := []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`) +- outputs := [][]byte{ +- []byte("zellij 0.44.3"), +- nil, +- nil, +- panesOut, +- } +- if runtime.GOOS == "windows" { +- outputs = append(outputs, panesOut) +- } +- outputs = append(outputs, []byte("sess-1 [Created 1s ago] \n")) +- fr := &fakeRunner{outputs: outputs} +- r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh", SocketDir: "/tmp/zj", ConfigDir: "/tmp/cfg"}) +- r.runner = fr +- +- handle, err := r.Create(context.Background(), ports.RuntimeConfig{ +- SessionID: "sess-1", +- WorkspacePath: "/tmp/ws", +- Argv: []string{"echo", "ready"}, +- Env: map[string]string{"AO_SESSION_ID": "sess-1"}, +- }) +- if err != nil { +- t.Fatalf("Create: %v", err) +- } +- if handle != (ports.RuntimeHandle{ID: "sess-1/terminal_3"}) { +- t.Fatalf("handle = %+v, want zellij handle", handle) +- } +- wantCalls := 5 +- if runtime.GOOS == "windows" { +- wantCalls = 6 +- } +- if len(fr.calls) != wantCalls { +- t.Fatalf("calls = %d, want %d", len(fr.calls), wantCalls) +- } +- if got, want := fr.calls[0].args, []string{"--config-dir", "/tmp/cfg", "--version"}; !reflect.DeepEqual(got, want) { +- t.Fatalf("version args = %#v, want %#v", got, want) +- } +- if got, want := fr.calls[1].args, []string{"--config-dir", "/tmp/cfg", "delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { +- t.Fatalf("delete args = %#v, want %#v", got, want) +- } +- if got := fr.calls[2].args[:5]; !reflect.DeepEqual(got, []string{"--config-dir", "/tmp/cfg", "attach", "--create-background", "sess-1"}) { +- t.Fatalf("create args prefix = %#v", got) +- } +- if got := fr.calls[3].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listPanesArgs("sess-1")...)) { +- t.Fatalf("list panes args = %#v", got) +- } +- listSessionsCall := 4 +- if runtime.GOOS == "windows" { +- if got := fr.calls[4].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listPanesArgs("sess-1")...)) { +- t.Fatalf("ready list panes args = %#v", got) +- } +- listSessionsCall = 5 +- } +- if got := fr.calls[listSessionsCall].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listSessionsArgs()...)) { +- t.Fatalf("list sessions args = %#v", got) +- } +- if got, want := fr.calls[0].env, expectedZellijEnv("/tmp/zj"); !reflect.DeepEqual(got, want) { +- t.Fatalf("env = %#v, want %#v", got, want) +- } +-} +- +-func TestCreateClearsStaleSessionBeforeCreating(t *testing.T) { +- panesOut := []byte(`[{"id":1,"is_plugin":false,"title":"agent"}]`) +- outputs := [][]byte{ +- []byte("zellij 0.44.3"), +- nil, +- nil, +- panesOut, +- } +- if runtime.GOOS == "windows" { +- outputs = append(outputs, panesOut) +- } +- outputs = append(outputs, []byte("sess-1 [Created 1s ago] \n")) +- fr := &fakeRunner{outputs: outputs} +- r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh"}) +- r.runner = fr +- +- if _, err := r.Create(context.Background(), ports.RuntimeConfig{ +- SessionID: "sess-1", +- WorkspacePath: "/tmp/ws", +- Argv: []string{"echo", "ready"}, +- }); err != nil { +- t.Fatalf("Create: %v", err) +- } +- +- wantCalls := 5 +- if runtime.GOOS == "windows" { +- wantCalls = 6 +- } +- if len(fr.calls) != wantCalls { +- t.Fatalf("calls = %d, want %d", len(fr.calls), wantCalls) +- } +- if got, want := fr.calls[1].args, []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { +- t.Fatalf("delete args = %#v, want %#v", got, want) +- } +- if got := fr.calls[2].args[:3]; !reflect.DeepEqual(got, []string{"attach", "--create-background", "sess-1"}) { +- t.Fatalf("create args prefix = %#v", got) +- } +-} +- +-func TestAttachCommandUsesEmbeddedClientOptions(t *testing.T) { +- r := New(Options{}) +- args, _, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) +- if err != nil { +- t.Fatalf("AttachCommand: %v", err) +- } +- embeddedOptions := []string{ +- "--pane-frames", "false", +- "--mouse-mode", "true", +- "--advanced-mouse-actions", "false", +- "--mouse-hover-effects", "false", +- "--focus-follows-mouse", "false", +- "--mouse-click-through", "false", +- "--support-kitty-keyboard-protocol", "false", +- } +- if runtime.GOOS == "windows" { +- joined := strings.Join(args, " ") +- for _, want := range embeddedOptions { +- if !strings.Contains(joined, want) { +- t.Fatalf("windows attach command missing %q: %#v", want, args) +- } +- } +- return +- } +- want := append(expectedAttachEnvPrefix(), r.binary, "attach", "sess-1", "options") +- want = append(want, embeddedOptions...) +- if !reflect.DeepEqual(args, want) { +- t.Fatalf("AttachCommand = %#v, want %#v", args, want) +- } +-} +- +-func TestAttachCommandUsesSocketDir(t *testing.T) { +- r := New(Options{SocketDir: "/tmp/zj"}) +- args, _, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) +- if err != nil { +- t.Fatalf("AttachCommand: %v", err) +- } +- if runtime.GOOS == "windows" { +- if got, want := args[0], r.binary; got != want { +- t.Fatalf("attach binary = %q, want %q", got, want) +- } +- return +- } +- if got, want := args[:6], []string{"env", "-u", "NO_COLOR", "TERM=xterm-256color", "COLORTERM=truecolor", "ZELLIJ_SOCKET_DIR=/tmp/zj"}; !reflect.DeepEqual(got, want) { +- t.Fatalf("attach prefix = %#v, want %#v", got, want) +- } +- if got, want := args[6], r.binary; got != want { +- t.Fatalf("attach binary = %q, want %q", got, want) +- } +-} +- +-func TestFindAgentPaneRetriesTransientErrors(t *testing.T) { +- fr := &fakeRunner{outputs: [][]byte{[]byte("boom"), []byte(`[{"id":0,"is_plugin":false,"title":"agent"}]`)}} +- r := New(Options{Timeout: time.Second}) +- r.runner = fr +- +- got, err := r.findAgentPane(context.Background(), "sess-1") +- if err != nil { +- t.Fatalf("findAgentPane: %v", err) +- } +- if got != "terminal_0" { +- t.Fatalf("findAgentPane = %q, want terminal_0", got) +- } +- if len(fr.calls) != 2 { +- t.Fatalf("calls = %d, want 2", len(fr.calls)) +- } +-} +- +-func TestParseVersion(t *testing.T) { +- for _, tc := range []struct { +- in string +- want semver +- }{ +- {in: "zellij 0.44.3", want: semver{0, 44, 3}}, +- {in: "zellij v1.2.3\n", want: semver{1, 2, 3}}, +- {in: "zellij 0.44.3-dev", want: semver{0, 44, 3}}, +- } { +- got, err := parseVersion(tc.in) +- if err != nil { +- t.Fatalf("parseVersion(%q): %v", tc.in, err) +- } +- if got != tc.want { +- t.Fatalf("parseVersion(%q) = %v, want %v", tc.in, got, tc.want) +- } +- } +- if _, err := parseVersion("zellij nope"); err == nil { +- t.Fatal("parseVersion invalid: got nil, want error") +- } +- if compareVersion(semver{0, 44, 2}, semver{0, 44, 3}) >= 0 { +- t.Fatal("compareVersion should order 0.44.2 before 0.44.3") +- } +- if got := RequiredVersion(); got != "0.44.3" { +- t.Fatalf("RequiredVersion = %q, want 0.44.3", got) +- } +- if got, err := CheckVersionOutput("zellij 0.44.3"); err != nil || got != "0.44.3" { +- t.Fatalf("CheckVersionOutput supported = %q, %v", got, err) +- } +- if _, err := CheckVersionOutput("zellij 0.44.2"); err == nil { +- t.Fatal("CheckVersionOutput unsupported: got nil error") +- } +-} +- +-func TestSendMessageChunksAndSendsEnter(t *testing.T) { +- fr := &fakeRunner{} +- r := New(Options{Timeout: time.Second, ChunkSize: 5}) +- r.runner = fr +- +- if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, "hello世界"); err != nil { +- t.Fatalf("SendMessage: %v", err) +- } +- if len(fr.calls) != 4 { +- t.Fatalf("calls = %d, want 4", len(fr.calls)) +- } +- if got, want := fr.calls[0].args, pasteArgs("sess-1", "terminal_0", "hello"); !reflect.DeepEqual(got, want) { +- t.Fatalf("paste 1 args = %#v, want %#v", got, want) +- } +- if got, want := fr.calls[1].args, pasteArgs("sess-1", "terminal_0", "世"); !reflect.DeepEqual(got, want) { +- t.Fatalf("paste 2 args = %#v, want %#v", got, want) +- } +- if got, want := fr.calls[2].args, pasteArgs("sess-1", "terminal_0", "界"); !reflect.DeepEqual(got, want) { +- t.Fatalf("paste 3 args = %#v, want %#v", got, want) +- } +- if got, want := fr.calls[3].args, sendEnterArgs("sess-1", "terminal_0"); !reflect.DeepEqual(got, want) { +- t.Fatalf("enter args = %#v, want %#v", got, want) +- } +-} +- +-func TestGetOutputTrimsLines(t *testing.T) { +- fr := &fakeRunner{outputs: [][]byte{[]byte("one\ntwo\nthree\n")}} +- r := New(Options{Timeout: time.Second}) +- r.runner = fr +- +- out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, 2) +- if err != nil { +- t.Fatalf("GetOutput: %v", err) +- } +- if out != "two\nthree\n" { +- t.Fatalf("output = %q, want last two lines", out) +- } +-} +- +-func TestGetOutputTrimsTrailingScreenPaddingBeforeTailing(t *testing.T) { +- fr := &fakeRunner{outputs: [][]byte{[]byte("ready\nprompt> echo hi\nhi\n\n\n\n")}} +- r := New(Options{Timeout: time.Second}) +- r.runner = fr +- +- out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, 2) +- if err != nil { +- t.Fatalf("GetOutput: %v", err) +- } +- if out != "prompt> echo hi\nhi\n" { +- t.Fatalf("output = %q, want last non-padding lines", out) +- } +-} +- +-func TestIsAliveParsesNoFormattingOutput(t *testing.T) { +- fr := &fakeRunner{outputs: [][]byte{[]byte("sess-1 [Created 1s ago] \nold [Created 2s ago] (EXITED - attach to resurrect)\n")}} +- r := New(Options{Timeout: time.Second}) +- r.runner = fr +- +- alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}) +- if err != nil { +- t.Fatalf("IsAlive: %v", err) +- } +- if !alive { +- t.Fatal("alive = false, want true") +- } +- if sessionListedAlive("sess-1-long [Created 1s ago]", "sess-1") { +- t.Fatal("prefix matched as alive") +- } +- if sessionListedAlive("sess-1 [Created 1s ago] (EXITED - attach to resurrect)", "sess-1") { +- t.Fatal("exited session matched as alive") +- } +-} +- +-// IsAlive may treat a non-zero list-sessions ONLY as "not alive" when zellij +-// says no sessions exist at all. Any other exit failure is a probe error: the +-// reaper reports it as a failed probe and the LCM must never read it as death. +-func TestIsAliveTreatsNoSessionsExitAsNotAlive(t *testing.T) { +- fr := &fakeRunner{outputs: [][]byte{[]byte("No active zellij sessions found.")}, err: &exec.ExitError{}} +- r := New(Options{Timeout: time.Second}) +- r.runner = fr +- +- alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}) +- if err != nil { +- t.Fatalf("IsAlive: %v", err) +- } +- if alive { +- t.Fatal("alive = true, want false") +- } +-} +- +-func TestIsAliveReportsOtherExitFailuresAsProbeErrors(t *testing.T) { +- fr := &fakeRunner{outputs: [][]byte{[]byte("thread 'main' panicked")}, err: &exec.ExitError{}} +- r := New(Options{Timeout: time.Second}) +- r.runner = fr +- +- alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}) +- if err == nil { +- t.Fatal("IsAlive: got nil error, want probe failure — a failed probe must not read as dead") +- } +- if alive { +- t.Fatal("alive = true on probe failure") +- } +-} +- +-func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { +- fr := &fakeRunner{outputs: [][]byte{[]byte("No active zellij sessions found.")}, err: &exec.ExitError{}} +- r := New(Options{Timeout: time.Second}) +- r.runner = fr +- +- if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}); err != nil { +- t.Fatalf("Destroy: %v", err) +- } +- if len(fr.calls) != 1 || fr.calls[0].args[0] != "delete-session" { +- t.Fatalf("calls = %#v, want only delete-session", fr.calls) +- } +-} +- +-func TestDestroyReportsUnexpectedExitFailures(t *testing.T) { +- fr := &fakeRunner{outputs: [][]byte{[]byte("permission denied")}, err: &exec.ExitError{}} +- r := New(Options{Timeout: time.Second}) +- r.runner = fr +- +- if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}); err == nil { +- t.Fatal("Destroy: got nil, want unexpected delete-session failure") +- } +-} +- +-// Destroy must delete the session's serialized state, not merely kill it: a +-// killed-but-cached session is resurrected (agent re-run included) by any later +-// `zellij attach`, bringing a terminated session's runtime back to life. +-func TestDestroyForceDeletesSerializedSession(t *testing.T) { +- fr := &fakeRunner{} +- r := New(Options{Timeout: time.Second}) +- r.runner = fr +- +- if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}); err != nil { +- t.Fatalf("Destroy: %v", err) +- } +- if len(fr.calls) != 1 { +- t.Fatalf("calls = %d, want 1", len(fr.calls)) +- } +- if got, want := fr.calls[0].args, []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { +- t.Fatalf("destroy args = %#v, want %#v", got, want) +- } +-} +- +-func TestGetOutputValidatesLines(t *testing.T) { +- r := New(Options{Timeout: time.Second}) +- _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, 0) +- if err == nil { +- t.Fatal("GetOutput lines=0: got nil, want error") +- } +-} +- +-type fakeRunner struct { +- calls []runnerCall +- outputs [][]byte +- err error +-} +- +-type runnerCall struct { +- env []string +- name string +- args []string +-} +- +-func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { +- f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) +- var out []byte +- if len(f.outputs) > 0 { +- out = f.outputs[0] +- f.outputs = f.outputs[1:] +- } +- if f.err != nil { +- return out, f.err +- } +- return out, nil +-} +- +-func (f *fakeRunner) Start(env []string, name string, args ...string) error { +- f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) +- if len(f.outputs) > 0 { +- f.outputs = f.outputs[1:] +- } +- return f.err +-} +- +-func TestCommandErrorUnwraps(t *testing.T) { +- base := errors.New("base") +- err := commandError{err: base, output: "details"} +- if !errors.Is(err, base) { +- t.Fatal("commandError should unwrap base error") +- } +- if !strings.Contains(err.Error(), "details") { +- t.Fatalf("error = %q, want output details", err.Error()) +- } +-} +diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go +index a52fa52..96492e3 100644 +--- a/backend/internal/cli/doctor.go ++++ b/backend/internal/cli/doctor.go +@@ -12,21 +12,20 @@ import ( + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" +- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + ) + + type doctorLevel string + + const ( + doctorPass doctorLevel = "PASS" + doctorWarn doctorLevel = "WARN" + doctorFail doctorLevel = "FAIL" + ) +@@ -284,25 +283,30 @@ func (c *commandContext) checkGit(ctx context.Context) doctorCheck { + cmp, err := compareDottedVersion(version, minGitVersion) + if err != nil { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version unknown: %s)", path, firstOutputLine(out))} + } + if cmp < 0 { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; AO expects >= %s for worktrees)", path, version, minGitVersion)} + } + return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; supports worktrees)", path, version)} + } + +-// checkTerminalRuntime checks for the runtime multiplexer used on this platform: +-// tmux on Darwin/Linux, zellij on Windows. ++// checkTerminalRuntime checks the runtime multiplexer used on this platform: ++// tmux on Darwin/Linux, ConPTY (built-in) on Windows. + func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { + if runtime.GOOS == "windows" { +- return c.checkZellij(ctx) ++ return doctorCheck{ ++ Level: doctorPass, ++ Section: doctorSectionTools, ++ Name: "conpty", ++ Message: "ConPTY (built-in): no external terminal multiplexer required on Windows", ++ } + } + return c.checkTmux(ctx) + } + + func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { + path, err := c.deps.LookPath("tmux") + if err != nil || path == "" { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"} + } + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) +@@ -311,38 +315,20 @@ func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { + if err != nil { + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s: %v", path, err)} + } + version := firstOutputLine(out) + if version == "" { + version = "version unknown" + } + return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s)", path, version)} + } + +-func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { +- path, err := c.deps.LookPath("zellij") +- if err != nil || path == "" { +- return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "zellij", Message: "not found in PATH"} +- } +- reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) +- defer cancel() +- out, err := c.deps.CommandOutput(reqCtx, path, "--version") +- if err != nil { +- return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} +- } +- version, err := zellij.CheckVersionOutput(string(out)) +- if err != nil { +- return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} +- } +- return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s (version %s; require >= %s)", path, version, zellij.RequiredVersion())} +-} +- + // checkHooksLog surfaces recent agent hook delivery failures. `ao hooks` + // callbacks deliberately swallow errors (a hook must never break the user's + // agent), so $AO_DATA_DIR/hooks.log is the only place a dead activity feed + // becomes visible. Lines start with an RFC3339 timestamp (see appendHooksLog). + func checkHooksLog(dataDir string, now time.Time) doctorCheck { + const name = "hooks-log" + path := filepath.Join(dataDir, hooksLogName) + data, err := os.ReadFile(path) //nolint:gosec // path rooted in AO's own data dir + if errors.Is(err, fs.ErrNotExist) { + return doctorCheck{Level: doctorPass, Section: doctorSectionCore, Name: name, Message: "no hook delivery failures recorded"} +diff --git a/backend/internal/cli/ptyhost.go b/backend/internal/cli/ptyhost.go +new file mode 100644 +index 0000000..d926597 +--- /dev/null ++++ b/backend/internal/cli/ptyhost.go +@@ -0,0 +1,29 @@ ++package cli ++ ++import ( ++ "os" ++ ++ "github.com/spf13/cobra" ++ ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty" ++) ++ ++// newPtyHostCommand registers the "ao pty-host" hidden subcommand that the ++// conpty runtime spawns on Windows to host a ConPTY session over loopback TCP. ++// DisableFlagParsing ensures agent shell args with leading dashes are not ++// consumed by cobra before being passed to RunHost. ++func newPtyHostCommand() *cobra.Command { ++ return &cobra.Command{ ++ Use: "pty-host", ++ Short: "Run a ConPTY pty-host process (internal)", ++ Hidden: true, ++ DisableFlagParsing: true, ++ RunE: func(_ *cobra.Command, args []string) error { ++ code := conpty.RunHost(args, os.Stdout) ++ if code != 0 { ++ os.Exit(code) ++ } ++ return nil ++ }, ++ } ++} +diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go +index 3bb1fce..da2db9c 100644 +--- a/backend/internal/cli/root.go ++++ b/backend/internal/cli/root.go +@@ -181,20 +181,21 @@ func NewRootCommand(deps Deps) *cobra.Command { + root.AddCommand(newDaemonCommand()) + root.AddCommand(newStartCommand(ctx)) + root.AddCommand(newStopCommand(ctx)) + root.AddCommand(newStatusCommand(ctx)) + root.AddCommand(newDoctorCommand(ctx)) + root.AddCommand(newSpawnCommand(ctx)) + root.AddCommand(newSendCommand(ctx)) + root.AddCommand(newPreviewCommand(ctx)) + root.AddCommand(newHooksCommand(ctx)) + root.AddCommand(newLaunchCommand(ctx)) ++ root.AddCommand(newPtyHostCommand()) + root.AddCommand(newImportCommand(ctx)) + root.AddCommand(newProjectCommand(ctx)) + root.AddCommand(newSessionCommand(ctx)) + root.AddCommand(newOrchestratorCommand(ctx)) + root.AddCommand(newReviewCommand(ctx)) + root.AddCommand(newCompletionCommand()) + root.AddCommand(newVersionCommand()) + + return root + } +diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go +index ae7bac4..d19d540 100644 +--- a/backend/internal/cli/spawn.go ++++ b/backend/internal/cli/spawn.go +@@ -3,21 +3,20 @@ package cli + import ( + "context" + "fmt" + "net/url" + "runtime" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" +- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + ) + + type spawnOptions struct { + project string + harness string + branch string + prompt string + issue string + claimPR string + noTakeover bool +@@ -94,29 +93,26 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { + out := cmd.OutOrStdout() + claimLabel := "" + if claimed != "" { + claimLabel = fmt.Sprintf(" (claimed %s)", claimed) + } + if _, err := fmt.Fprintf(out, "spawned session %s (%s)%s\n", res.Session.ID, res.Session.Status, claimLabel); err != nil { + return err + } + // Print a copy-pasteable attach hint for the selected runtime. + // On Darwin/Linux: tmux attach-session using the sanitised session name. +- // On Windows: zellij attach with the short socket dir prefix. ++ // On Windows: ConPTY has no user-facing attach CLI; use the AO dashboard. + var attach string + if runtime.GOOS != "windows" { + attach = fmt.Sprintf("tmux attach -t %s", tmux.SessionName(res.Session.ID)) + } else { +- attach = fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) +- if dir := zellij.DefaultSocketDir(); dir != "" { +- attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) +- } ++ attach = "Attach from the AO dashboard (ConPTY sessions have no CLI attach command)" + } + _, err := fmt.Fprintf(out, "attach with: %s\n", attach) + return err + }, + } + f := cmd.Flags() + // --agent is an alias for --harness so the more intuitive `ao spawn --agent + // droid` works identically; both resolve to the same harness flag. + f.SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName { + if name == "agent" { +diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go +index 5d21160..add2473 100644 +--- a/backend/internal/daemon/lifecycle_wiring.go ++++ b/backend/internal/daemon/lifecycle_wiring.go +@@ -120,21 +120,21 @@ func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlit + Sessions: store, + PRs: store, + Projects: store, + Launcher: reviewcore.NewLauncher(reviewers, runtime), + }) + reviewSvc := reviewsvc.New(reviewEngine, store, reviewsvc.WithLifecycleReducer(lcm)) + return sessionSvc, reviewSvc, nil + } + + // runtimeMessageSender is the narrow part of the concrete runtime needed by +-// ao send. zellij.Runtime already implements this via SendMessage. ++// ao send. Both tmux.Runtime and conpty.Runtime implement this via SendMessage. + type runtimeMessageSender interface { + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error + } + + // runtimeMessenger sends the user's message directly to the session's live + // runtime pane. The HTTP controller has already validated and sanitized the + // message body; this adapter only resolves the stored runtime handle. + type runtimeMessenger struct { + store *sqlite.Store + runtime runtimeMessageSender +diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go +index 21bd248..eda8ac1 100644 +--- a/backend/internal/daemon/wiring_test.go ++++ b/backend/internal/daemon/wiring_test.go +@@ -4,21 +4,21 @@ import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" +- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + telemetryadapter "github.com/aoagents/agent-orchestrator/backend/internal/adapters/telemetry" + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" + ) + +@@ -314,21 +314,21 @@ func TestWiring_StartLifecycleThreadsMessengerIntoLCM(t *testing.T) { + rec, err := store.CreateSession(ctx, domain.SessionRecord{ + ProjectID: "p", Kind: domain.KindWorker, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: time.Now()}, + }) + if err != nil { + t.Fatal(err) + } + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + messenger := &captureMessenger{} +- stack := startLifecycle(ctx, store, zellij.New(zellij.Options{}), messenger, nil, nil, log) ++ stack := startLifecycle(ctx, store, tmux.New(tmux.Options{}), messenger, nil, nil, log) + t.Cleanup(stack.Stop) + t.Cleanup(cancel) + + obs := ports.SCMObservation{ + Fetched: true, + PR: ports.SCMPRObservation{URL: "https://github.com/o/r/pull/1", Number: 1, HeadSHA: "c1"}, + CI: ports.SCMCIObservation{ + Summary: string(domain.CIFailing), + HeadSHA: "c1", + FailedChecks: []ports.SCMCheckObservation{{Name: "build", Status: string(domain.PRCheckFailed), LogTail: "boom"}}, +@@ -371,29 +371,10 @@ func TestProjectRepoResolver_ResolvesRegisteredProject(t *testing.T) { + _, err = r.RepoPath("nope") + if err == nil { + t.Fatal("expected an error for an unregistered project") + } + // Guard the sentinel wrapping so the HTTP 400 mapping can't silently regress. + if !errors.Is(err, sessionmanager.ErrProjectNotResolvable) { + t.Fatalf("unregistered-project error should wrap ErrProjectNotResolvable, got %v", err) + } + } + +-// TestDaemonZellijSocketDir_LeavesBudgetForSessionNames guards the fix for the +-// zellij "session name must be less than 0 characters" spawn failure: the +-// daemon's socket dir must be short enough that a max-length (48-char) session +-// name still fits the ~103-byte unix-domain-socket-path budget. zellij's long +-// $TMPDIR default (the bug) would fail this. +-func TestDaemonZellijSocketDir_LeavesBudgetForSessionNames(t *testing.T) { +- dir := zellij.DefaultSocketDir() +- if dir == "" { +- t.Skip("zellij not used on this platform") +- } +- const ( +- unixSocketPathMax = 103 // sun_path budget zellij enforces on macOS +- zellijOverhead = 24 // zellij's version subdir + separators (generous) +- maxSessionName = 48 // zellijSessionName's cap +- ) +- if budget := unixSocketPathMax - len(dir) - zellijOverhead; budget < maxSessionName { +- t.Fatalf("zellij socket dir %q too long: %d bytes left for the session name, need >= %d", dir, budget, maxSessionName) +- } +-} +diff --git a/backend/internal/terminal/attachment_integration_test.go b/backend/internal/terminal/attachment_integration_test.go +index 452362f..940a919 100644 +--- a/backend/internal/terminal/attachment_integration_test.go ++++ b/backend/internal/terminal/attachment_integration_test.go +@@ -1,97 +1,76 @@ + //go:build !windows + + package terminal + + import ( + "context" + "os" + "os/exec" +- "path/filepath" + "strconv" + "strings" + "testing" + "time" + +- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" ++ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + ) + +-// TestAttachmentStreamsRealZellijPane attaches a real PTY to a real Zellij +-// session and asserts output streams back, then that killing the pane stops the +-// attachment without a re-attach storm. Skipped when Zellij is unavailable. +-func TestAttachmentStreamsRealZellijPane(t *testing.T) { +- zellijBin, err := exec.LookPath("zellij") +- if err != nil { +- t.Skip("zellij unavailable") ++// TestAttachmentStreamsRealTmuxPane attaches a real PTY to a real tmux session ++// and asserts output streams back, then that killing the session stops the ++// attachment without a re-attach storm. Skipped when tmux is unavailable. ++func TestAttachmentStreamsRealTmuxPane(t *testing.T) { ++ if _, err := exec.LookPath("tmux"); err != nil { ++ t.Skip("tmux unavailable") + } + + name := "ao-term-it-" + strconv.Itoa(os.Getpid()) +- socketDir := filepath.Join("/tmp", name+"-socket") +- if err := os.MkdirAll(socketDir, 0o755); err != nil { +- t.Fatalf("mkdir socket dir: %v", err) +- } +- rt := zellij.New(zellij.Options{Binary: zellijBin, SocketDir: socketDir, ConfigDir: t.TempDir(), Timeout: 5 * time.Second}) ++ rt := tmux.New(tmux.Options{Timeout: 10 * time.Second}) + handle, err := rt.Create(context.Background(), ports.RuntimeConfig{ + SessionID: domain.SessionID(name), + WorkspacePath: t.TempDir(), + Argv: []string{"sh", "-lc", "printf AO_READY\\n; exec sh -i"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) }) + + var got safeBytes + a := newAttachment(name, handle, rt, nil, got.add, nil, testLogger()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.run(ctx) + + // Type a unique marker and expect it echoed back through the PTY. + eventually(t, 3*time.Second, func() bool { return a.write([]byte("echo AO_MARKER_42\n")) == nil }) + eventually(t, 5*time.Second, func() bool { return strings.Contains(got.string(), "AO_MARKER_42") }) + +- // A fresh attach must carry zellij's alt-screen init handshake. Mouse +- // reporting is deliberately disabled for AO's embedded client, so this test +- // should not require SGR mouse mode. +- eventually(t, 5*time.Second, func() bool { +- out := got.string() +- return strings.Contains(out, "\x1b[?1049h") +- }) +- + // Kill the session: the attachment must observe it as gone and not re-attach. + if err := rt.Destroy(context.Background(), handle); err != nil { + t.Fatalf("Destroy: %v", err) + } + eventually(t, 5*time.Second, func() bool { return a.isExited() }) + } + + // TestAttachmentReattachAdoptsNewSize is the end-to-end regression for the + // stale-size desync: client A holds the session at one grid, detaches, and + // client B immediately attaches at a different grid (the frontend's +-// remount/reconnect flow). B's zellij client must adopt B's size — the inner +-// pane's tty must report it — not stay laid out for A's. This is where the +-// spawn-at-size + explicit-WINCH + SIGTERM-detach fixes meet a real zellij. ++// remount/reconnect flow). B's tmux client must adopt B's size, not A's. + func TestAttachmentReattachAdoptsNewSize(t *testing.T) { +- zellijBin, err := exec.LookPath("zellij") +- if err != nil { +- t.Skip("zellij unavailable") ++ if _, err := exec.LookPath("tmux"); err != nil { ++ t.Skip("tmux unavailable") + } + + name := "ao-term-size-it-" + strconv.Itoa(os.Getpid()) +- socketDir := filepath.Join("/tmp", name+"-socket") +- if err := os.MkdirAll(socketDir, 0o755); err != nil { +- t.Fatalf("mkdir socket dir: %v", err) +- } +- rt := zellij.New(zellij.Options{Binary: zellijBin, SocketDir: socketDir, ConfigDir: t.TempDir(), Timeout: 5 * time.Second}) ++ rt := tmux.New(tmux.Options{Timeout: 10 * time.Second}) + handle, err := rt.Create(context.Background(), ports.RuntimeConfig{ + SessionID: domain.SessionID(name), + WorkspacePath: t.TempDir(), + Argv: []string{"sh", "-lc", "printf AO_READY\\n; exec sh -i"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) }) + +@@ -104,47 +83,45 @@ func TestAttachmentReattachAdoptsNewSize(t *testing.T) { + } + ctx, cancel := context.WithCancel(context.Background()) + go a.run(ctx) + return a, &got, opened, cancel + } + + // Client A at 115x37: wait for the pane shell, then detach. + a, _, openedA, cancelA := attachAt(37, 115) + select { + case <-openedA: +- case <-time.After(5 * time.Second): ++ case <-time.After(10 * time.Second): + t.Fatal("client A did not attach") + } + a.close() + cancelA() + +- // Client B re-attaches immediately at 148x40 — no settle gap, same as the +- // frontend reconnecting. The inner pane must see B's grid (zellij chrome +- // shaves a couple rows/cols, so assert the reported cols land near 148 and +- // far from 115). ++ // Client B re-attaches immediately at 148x40. The inner pane must see B's ++ // grid (tmux may shave a row/col; assert cols land near 148 and far from 115). + b, gotB, openedB, cancelB := attachAt(40, 148) + defer cancelB() + defer b.close() + select { + case <-openedB: +- case <-time.After(5 * time.Second): ++ case <-time.After(10 * time.Second): + t.Fatal("client B did not attach") + } + + eventually(t, 5*time.Second, func() bool { return b.write([]byte("echo SIZE:$(stty size)\n")) == nil }) + eventually(t, 10*time.Second, func() bool { + out := gotB.string() + i := strings.LastIndex(out, "SIZE:") + if i < 0 { + return false + } + fields := strings.Fields(strings.TrimPrefix(out[i:], "SIZE:")) + if len(fields) < 2 { + return false + } + cols, err := strconv.Atoi(strings.TrimFunc(fields[1], func(r rune) bool { return r < '0' || r > '9' })) + if err != nil { + return false + } +- return cols > 130 // B's 148 minus zellij chrome; a stale A-layout reports ≤115 ++ return cols > 130 // B's 148 minus any tmux chrome; a stale A-layout reports <=115 + }) + } + +--- Changes --- + +backend/internal/adapters/runtime/runtimeselect/runtimeselect.go + @@ -1,47 +1,37 @@ + -// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). + +// tmux on Darwin/Linux, conpty (ConPTY) on Windows. + package runtimeselect + + import ( + "context" + "log/slog" + - "os" + "runtime" + + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + ) + + -// Runtime is the union interface that both tmux and zellij satisfy. + +// Runtime is the union interface that both tmux and conpty satisfy. + // It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods + // the daemon wires directly, including ports.Attacher (Attach) so the terminal + // layer can open a Stream against the selected runtime. + type Runtime interface { + ports.Runtime // Create, Destroy, IsAlive + ports.Attacher + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error + GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) + } + + // Compile-time assertions: both adapters must implement the union interface. + var _ Runtime = (*tmux.Runtime)(nil) + -var _ Runtime = (*zellij.Runtime)(nil) + +var _ Runtime = (*conpty.Runtime)(nil) + + -// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. + -// log is used only for the Windows zellij socket-dir warning; nil is safe. + -func New(log *slog.Logger) Runtime { + +// New returns the per-platform runtime: tmux on Darwin/Linux, conpty on Windows. + +// log is accepted for signature stability with callers but is currently unused. + +func New(_ *slog.Logger) Runtime { + if runtime.GOOS != "windows" { + return tmux.New(tmux.Options{}) + } + - // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. + - dir := zellij.DefaultSocketDir() + - if dir != "" { + - if err := os.MkdirAll(dir, 0o700); err != nil { + - if log != nil { + - log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) + - } + - } + - } + - return zellij.New(zellij.Options{SocketDir: dir}) + + return conpty.New(conpty.Options{}) + } + +8 -18 + +backend/internal/adapters/runtime/zellij/commands.go + @@ -1,405 +0,0 @@ + -package zellij + - + -import ( + - "encoding/base64" + - "encoding/binary" + - "fmt" + - "runtime" + - "sort" + - "strconv" + - "strings" + - "unicode/utf16" + - + - "github.com/aoagents/agent-orchestrator/backend/internal/ports" + -) + - + -const ( + - agentPaneName = "agent" + - defaultChunkBytes = 16 * 1024 + -) + - + -func versionArgs() []string { + - return []string{"--version"} + -} + - + -func createSessionArgs(id, layoutPath string) []string { + - clientOptions := embeddedClientOptions() + - args := make([]string, 0, 6+len(clientOptions)+6) + - args = append(args, + - "attach", "--create-background", id, + - "options", + - "--default-layout", layoutPath, + - ) + - args = append(args, clientOptions...) + - args = append(args, + - "--session-serialization", "false", + - "--show-startup-tips", "false", + - "--show-release-notes", "false", + - ) + - return args + -} + - + -func listPanesArgs(id string) []string { + - return []string{"--session", id, "action", "list-panes", "--all", "--json"} + -} + - + -func pasteArgs(id, paneID, chunk string) []string { + - if runtime.GOOS == "windows" { + - return []string{"--session", id, "action", "write-chars", "--pane-id", paneID, chunk} + - } + - return []string{"--session", id, "action", "paste", "--pane-id", paneID, chunk} + -} + - + -func sendEnterArgs(id, paneID string) []string { + - return []string{"--session", id, "action", "send-keys", "--pane-id", paneID, "Enter"} + -} + - + -func dumpScreenArgs(id, paneID string) []string { + - return []string{"--session", id, "action", "dump-screen", "--pane-id", paneID, "--full"} + -} + - + -func listSessionsArgs() []string { + - return []string{"list-sessions", "--no-formatting"} + -} + - + -// deleteSessionArgs builds the teardown command. `delete-session --force` + -// kills a running session AND removes its serialized resurrection state in one + -// step. Plain `kill-session` is not enough: zellij can keep the session in its + -// global resurrection cache as "(EXITED - attach to resurrect)", and any later + -// `zellij attach ` (e.g. the terminal mux re-opening a pane) would resurrect + -// it — re-running the agent command for a session the daemon already destroyed. + -func deleteSessionArgs(id string) []string { + - return []string{"delete-session", "--force", id} + -} + - + -func attachArgs(id string) []string { + - clientOptions := embeddedClientOptions() + - args := make([]string, 0, 3+len(clientOptions)) + - args = append(args, + - "attach", id, + - "options", + - ) + - args = append(args, clientOptions...) + - return args + -} + - + -func embeddedClientOptions() []string { + - return []string{ + - "--pane-frames", "false", + - // The dashboard terminal disables xterm local scrollback and lets the + - // embedded zellij client own scrollback. Keep mouse mode on so wheel + - // reports reach zellij, while leaving richer pointer behaviors off. + - "--mouse-mode", "true", + - "--advanced-mouse-actions", "false", + - "--mouse-hover-effects", "false", + - "--focus-follows-mouse", "false", + - "--mouse-click-through", "false", + - "--support-kitty-keyboard-protocol", "false", + - } + -} + - + ... (305 lines truncated) + +0 -405 + +backend/internal/adapters/runtime/zellij/process_other.go + @@ -1,18 +0,0 @@ + -//go:build !windows + - + -package zellij + - + -import ( + - "errors" + - "os/exec" + -) + - + -// hideWindow is a no-op off Windows: only Windows pops a console window. + -func hideWindow(*exec.Cmd) {} + - + -// startBackgroundProcess is a stub: the fire-and-forget path is only used by + -// the Windows zellij codepath. Non-Windows builds create sessions + -// synchronously via runner.Run. + -func startBackgroundProcess(env []string, name string, args ...string) error { + - return errors.New("zellij runtime: background spawn is windows-only") + -} + +0 -18 + +backend/internal/adapters/runtime/zellij/process_windows.go + @@ -1,79 +0,0 @@ + -//go:build windows + - + -package zellij + - + -import ( + - "os/exec" + - "strings" + - "syscall" + - + - "golang.org/x/sys/windows" + -) + - + -// hideWindow suppresses the console window for a console-subsystem child so + -// frequent zellij calls (e.g. the reaper's list-sessions) don't flash on Windows. + -func hideWindow(cmd *exec.Cmd) { + - cmd.SysProcAttr = &syscall.SysProcAttr{ + - HideWindow: true, + - CreationFlags: windows.CREATE_NO_WINDOW, + - } + -} + - + -func startBackgroundProcess(env []string, name string, args ...string) error { + - script := "Start-Process -FilePath " + psQuote(name) + " -ArgumentList " + psQuote(windowsCommandLine(args)) + " -WindowStyle Hidden" + - cmd := exec.Command("powershell.exe", "-NoLogo", "-NoProfile", "-EncodedCommand", powerShellEncodedCommand(script)) + - cmd.Env = env + - cmd.SysProcAttr = &syscall.SysProcAttr{ + - // CREATE_NO_WINDOW, not CREATE_NEW_CONSOLE: the latter creates a console + - // then hides it, which flashed a window. + - CreationFlags: windows.CREATE_NO_WINDOW, + - HideWindow: true, + - } + - if err := cmd.Start(); err != nil { + - return err + - } + - go func() { _ = cmd.Wait() }() + - return nil + -} + - + -func windowsCommandLine(args []string) string { + - quoted := make([]string, len(args)) + - for i, arg := range args { + - quoted[i] = windowsQuoteArg(arg) + - } + - return strings.Join(quoted, " ") + -} + - + -func windowsQuoteArg(arg string) string { + - if arg == "" { + - return `""` + - } + - if !strings.ContainsAny(arg, " \t\"") { + - return arg + - } + - + - var b strings.Builder + - b.WriteByte('"') + - backslashes := 0 + - for _, r := range arg { + - switch r { + - case '\\': + - backslashes++ + - case '"': + - b.WriteString(strings.Repeat(`\`, backslashes*2+1)) + - b.WriteRune(r) + - backslashes = 0 + - default: + - if backslashes > 0 { + - b.WriteString(strings.Repeat(`\`, backslashes)) + - backslashes = 0 + - } + - b.WriteRune(r) + - } + - } + - if backslashes > 0 { + - b.WriteString(strings.Repeat(`\`, backslashes*2)) + - } + - b.WriteByte('"') + - return b.String() + -} + +0 -79 + +backend/internal/adapters/runtime/zellij/zellij.go + @@ -1,964 +0,0 @@ + -// 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/adapters/runtime/ptyexec" + - "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) + -var _ ports.Attacher = (*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() + -} + ... (864 lines truncated) + +0 -964 + +backend/internal/adapters/runtime/zellij/zellij_integration_test.go + @@ -1,165 +0,0 @@ + -package zellij + - + -import ( + - "context" + - "os" + - "os/exec" + - "path/filepath" + - "runtime" + - "strings" + - "testing" + - "time" + - + - "github.com/aoagents/agent-orchestrator/backend/internal/ports" + -) + - + -func TestRuntimeIntegration(t *testing.T) { + - if _, err := exec.LookPath("zellij"); err != nil { + - t.Skip("zellij unavailable") + - } + - + - ctx := context.Background() + - id := "ao_itest_zj" + - socketDir := tempSocketDir(t, "ao-zj-itest-") + - configDir := t.TempDir() + - opts := Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: configDir} + - if runtime.GOOS == "windows" { + - opts.Timeout = 30 * time.Second + - opts.LauncherBinary = buildAOForIntegration(t) + - opts.Shell = "cmd.exe" + - } + - r := New(opts) + - _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) + - argv := []string{"sh", "-lc", "printf ready-$AO_SESSION_ID\\n; exec sh -i"} + - sendCommand := "echo hello-from-zellij" + - if runtime.GOOS == "windows" { + - argv = []string{"cmd.exe", "/D", "/Q", "/K", "echo ready-%AO_SESSION_ID%"} + - } + - + - h, err := r.Create(ctx, ports.RuntimeConfig{ + - SessionID: "ao_itest_zj", + - WorkspacePath: t.TempDir(), + - Argv: argv, + - Env: map[string]string{"AO_SESSION_ID": id}, + - }) + - if err != nil { + - t.Fatalf("Create: %v", err) + - } + - defer r.Destroy(ctx, h) + - + - alive, err := r.IsAlive(ctx, h) + - if err != nil { + - t.Fatalf("IsAlive: %v", err) + - } + - if !alive { + - t.Fatal("alive = false, want true") + - } + - prefixAlive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: "ao_itest"}) + - if err != nil { + - t.Fatalf("IsAlive prefix: %v", err) + - } + - if prefixAlive { + - t.Fatal("prefix handle reported alive; zellij session matching is not exact") + - } + - + - out := waitForRuntimeOutput(t, r, h, "ready-") + - if !strings.Contains(out, "ready-") { + - t.Fatalf("output = %q, want ready output", out) + - } + - if err := r.SendMessage(ctx, h, sendCommand); err != nil { + - t.Fatalf("SendMessage: %v", err) + - } + - out = waitForRuntimeOutput(t, r, h, "hello-from-zellij") + - if !strings.Contains(out, "hello-from-zellij") { + - t.Fatalf("output = %q, want sent command output", out) + - } + - + - if err := r.Destroy(ctx, h); err != nil { + - t.Fatalf("Destroy: %v", err) + - } + - alive, err = r.IsAlive(ctx, h) + - if err != nil { + - t.Fatalf("IsAlive after destroy: %v", err) + - } + - if alive { + - t.Fatal("alive after destroy = true, want false") + - } + -} + - + -func waitForRuntimeOutput(t *testing.T, r *Runtime, h ports.RuntimeHandle, want string) string { + - t.Helper() + - deadline := time.Now().Add(3 * time.Second) + - var out string + - for time.Now().Before(deadline) { + - var err error + - out, err = r.GetOutput(context.Background(), h, 30) + - if err != nil { + - t.Fatalf("GetOutput: %v", err) + - } + - if strings.Contains(out, want) { + - return out + ... (65 lines truncated) + +0 -165 + +backend/internal/adapters/runtime/zellij/zellij_test.go + @@ -1,734 +0,0 @@ + -package zellij + - + -import ( + - "context" + - "errors" + - "os/exec" + - "reflect" + - "runtime" + - "strings" + - "testing" + - "time" + - + - "github.com/aoagents/agent-orchestrator/backend/internal/domain" + - "github.com/aoagents/agent-orchestrator/backend/internal/ports" + -) + - + -func TestNewDefaultsToPortableShell(t *testing.T) { + - t.Setenv("SHELL", "") + - r := New(Options{}) + - want := "/bin/sh" + - if runtime.GOOS == "windows" { + - want = "powershell.exe" + - } + - if got := r.shell; got != want { + - t.Fatalf("default shell = %q, want %q", got, want) + - } + +... (more changes truncated) + +0 -26 +[full diff: rtk git diff --no-compact] diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 29ca5ef6..bc4a0e89 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -82,8 +82,8 @@ func Run() error { return err } - // Terminal streaming: the selected runtime (tmux on macOS/Linux, zellij on Windows) supplies the - // PTY-attach command and liveness; the CDC broadcaster feeds the session-state channel. The manager + // Terminal streaming: the selected runtime (tmux on macOS/Linux, conpty on Windows) supplies the + // attach Stream and liveness; the CDC broadcaster feeds the session-state channel. The manager // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow // through the CDC change_log -- only session-state events do. runtimeAdapter := runtimeselect.New(log) From 284e8403537fdf223878385661c6993f273ca557 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 24 Jun 2026 00:47:19 +0530 Subject: [PATCH 21/60] docs(ptyexec): drop stale zellij reference in Windows spawn comment Co-Authored-By: Claude Opus 4.8 --- .../sdd/final-phaseB-review-package.txt | 73 +++++++++++++++++++ .superpowers/sdd/progress.md | 2 +- .../adapters/runtime/ptyexec/spawn_windows.go | 4 +- 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 .superpowers/sdd/final-phaseB-review-package.txt diff --git a/.superpowers/sdd/final-phaseB-review-package.txt b/.superpowers/sdd/final-phaseB-review-package.txt new file mode 100644 index 00000000..e30b19fb --- /dev/null +++ b/.superpowers/sdd/final-phaseB-review-package.txt @@ -0,0 +1,73 @@ +=== BRANCH COMMITS (since e970f72) === +3b7ff9c docs(daemon): correct terminal-runtime comment to conpty on Windows +6a18394 feat(runtime): select conpty on Windows, register pty-host subcommand... +b9ef1f5 chore(sdd): B6 brief + B5 ledger +1511f1a style(ptyexec): replace em dashes carried from moved pty files +5bfbc48 feat(terminal): stream-based Attach for tmux/zellij/conpty +8d757ed chore(sdd): B5 brief + ledger +0bc26e5 fix(conpty): split IsAlive dead-vs-transient for reaper safety +4aac665 feat(conpty): add runtime adapter with loopback pty-client and sessio... +be06609 chore(sdd): B4 brief +6cd6e2a fix(conpty): deliver scrollback snapshot and register client atomically +a7b052b feat(conpty): add pty-host serve engine with loopback TCP transport (B3) +41ce417 chore(sdd): phase B briefs and progress for B1-B3 +6552e26 feat(ptyregistry): port Windows pty-host sideband registry to Go +d67941b test(conpty): harden copy-safety and add concurrent ring test +1050542 feat(conpty): add protocol codec and output ring buffer (pure Go, OS-... +926ee31 refactor(tmux): drop unused runner.Start seam +4b21e4b chore: tidy runtime-neutral comments and doctor import grouping +b459abf feat(runtime): wire tmux on Darwin/Linux via runtimeselect, keep zell... +44c3e61 fix(tmux): address four code-review findings in tmux runtime adapter +bc23964 feat(runtime): add tmux adapter package + +=== FULL STAT (backend) === + .../internal/adapters/runtime/conpty/host_main.go | 84 ++ + .../internal/adapters/runtime/conpty/host_test.go | 593 +++++++++++++ + .../adapters/runtime/conpty/pidalive_unix.go | 26 + + .../adapters/runtime/conpty/pidalive_windows.go | 30 + + backend/internal/adapters/runtime/conpty/proto.go | 89 ++ + .../internal/adapters/runtime/conpty/proto_test.go | 190 +++++ + .../runtime/conpty/ptyregistry/pidalive_unix.go | 19 + + .../runtime/conpty/ptyregistry/pidalive_windows.go | 19 + + .../runtime/conpty/ptyregistry/ptyregistry_test.go | 229 +++++ + .../runtime/conpty/ptyregistry/registry.go | 164 ++++ + backend/internal/adapters/runtime/conpty/ring.go | 83 ++ + .../internal/adapters/runtime/conpty/ring_test.go | 161 ++++ + .../internal/adapters/runtime/conpty/runtime.go | 234 +++++ + .../adapters/runtime/conpty/runtime_test.go | 676 +++++++++++++++ + backend/internal/adapters/runtime/conpty/spawn.go | 11 + + .../adapters/runtime/conpty/spawn_other.go | 16 + + .../adapters/runtime/conpty/spawn_windows.go | 121 +++ + .../runtime/ptyexec/spawn_unix.go} | 39 +- + .../runtime/ptyexec/spawn_unix_test.go} | 24 +- + .../runtime/ptyexec/spawn_windows.go} | 21 +- + .../runtime/ptyexec/spawn_windows_test.go} | 20 +- + .../runtime/runtimeselect/runtimeselect.go | 37 + + backend/internal/adapters/runtime/tmux/commands.go | 64 ++ + backend/internal/adapters/runtime/tmux/tmux.go | 488 +++++++++++ + .../adapters/runtime/tmux/tmux_integration_test.go | 143 ++++ + .../internal/adapters/runtime/tmux/tmux_test.go | 602 +++++++++++++ + .../internal/adapters/runtime/zellij/commands.go | 405 --------- + .../adapters/runtime/zellij/process_other.go | 18 - + .../adapters/runtime/zellij/process_windows.go | 79 -- + backend/internal/adapters/runtime/zellij/zellij.go | 950 --------------------- + .../runtime/zellij/zellij_integration_test.go | 165 ---- + .../adapters/runtime/zellij/zellij_test.go | 734 ---------------- + backend/internal/cli/doctor.go | 36 +- + backend/internal/cli/doctor_test.go | 38 +- + backend/internal/cli/ptyhost.go | 29 + + backend/internal/cli/root.go | 1 + + backend/internal/cli/spawn.go | 19 +- + backend/internal/daemon/daemon.go | 24 +- + backend/internal/daemon/lifecycle_wiring.go | 8 +- + backend/internal/daemon/wiring_test.go | 30 +- + backend/internal/httpd/terminal_mux_test.go | 13 +- + backend/internal/ports/outbound.go | 15 + + backend/internal/terminal/attachment.go | 98 +-- + .../terminal/attachment_integration_test.go | 61 +- + backend/internal/terminal/attachment_test.go | 112 +-- + backend/internal/terminal/fakes_test.go | 29 +- + backend/internal/terminal/logger_test.go | 4 +- + backend/internal/terminal/manager.go | 25 +- + backend/internal/terminal/manager_test.go | 54 +- + 55 files changed, 5296 insertions(+), 2706 deletions(-) diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index c45cb582..d7464668 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -30,7 +30,7 @@ Tasks: - B3: pty-host serve engine (loopback TCP, NOT named pipe; ConPTY behind interface seam) — COMPLETE (41ce417..6cd6e2a, 35 tests -race clean incl. ordering regression test, review APPROVED; loopback decision documented; Windows go-pty file compile-checked only) - B4: conpty runtime adapter — COMPLETE (be06609..0bc26e5, 49 tests -race, IsAlive dead-vs-transient fixed, registry-recovery path; Windows spawn file compile-checked only) - B5: terminal Attach/Stream interface change + tmux/zellij/conpty Attach — COMPLETE (8d757ed..HEAD, 1638 tests -race across 78 pkgs, all 3 GOOS, real tmux+zellij attach integration pass, review APPROVED; conpty loopbackStream stress-tested clean) -- B6: select conpty on Windows + delete zellij + doctor/spawn + register pty-host subcommand — NOT STARTED +- B6: select conpty on Windows + delete zellij + pty-host subcommand + doctor/spawn — COMPLETE (b9ef1f5..HEAD, 1607 tests -race across 77 pkgs, all 3 GOOS, zellij GONE, arg-contract verified, review APPROVED) Faithful-port references (agent-orchestrator): - packages/plugins/runtime-process/src/pty-host.ts (protocol, ring, fan-out, shutdown) diff --git a/backend/internal/adapters/runtime/ptyexec/spawn_windows.go b/backend/internal/adapters/runtime/ptyexec/spawn_windows.go index b0b019b6..8c7136a0 100644 --- a/backend/internal/adapters/runtime/ptyexec/spawn_windows.go +++ b/backend/internal/adapters/runtime/ptyexec/spawn_windows.go @@ -22,8 +22,8 @@ const detachGrace = 250 * time.Millisecond // creates the pseudo-console at 80x25 internally, so we only Resize when the // caller actually has a grid (mirroring StartWithSize on Unix). env, when // non-nil, replaces the inherited environment via Win32's native CreateProcess -// env block (mirrors exec.Cmd.Env semantics); this is how a per-session -// ZELLIJ_SOCKET_DIR reaches the zellij attach client. +// env block (mirrors exec.Cmd.Env semantics); this is how a per-session env var +// reaches the spawned attach client. func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) { if len(argv) == 0 { return nil, errors.New("ptyexec: empty attach command") From 25caa1f368b711ccca0dfa107200d0d1a60069a6 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 24 Jun 2026 00:47:19 +0530 Subject: [PATCH 22/60] chore(sdd): final phase B ledger --- .superpowers/sdd/progress.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index d7464668..5cf59b05 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -43,3 +43,10 @@ Faithful-port references (agent-orchestrator): ## Ledger (append "Task N: complete (commits .., review clean)" as tasks finish) + +## FINAL: Phase B SHIP (whole-branch review) +All 6 Phase B tasks complete + reviewed. End state: tmux (Darwin/Linux), conpty (Windows), zellij deleted. +darwin/linux/windows build; 1607 tests -race across 77 pkgs; vet clean. +VERIFIED on Darwin: full tmux path + all cross-platform conpty internals (protocol, ring, host serve engine over real loopback w/ fake ptyConn, client/attach, registry, dead-vs-transient IsAlive, injectable-spawn Create/Destroy). +UNVERIFIED (needs Windows hardware): real go-pty ConPTY child spawn, detached-spawn survival, OpenProcess pidAlive, 0x800700e8 cleanup ordering. +HEAD = 284e840 From 1c4ce2fdfdfdf27fab29cc29a78acb63ad8a6375 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 24 Jun 2026 04:23:17 +0530 Subject: [PATCH 23/60] build(desktop): support local keychain signing for macOS builds Bridge forge.config.ts to accept the local keychain flow (APPLE_SIGNING_IDENTITY identity + AO_NOTARY_PROFILE notarytool profile) in addition to the existing CI secrets path (CSC_LINK + APPLE_ID/app-specific-password). Enables a signed + notarized macOS build from a developer Mac without exporting a .p12 or the Apple ID app-specific password. Co-Authored-By: Claude Opus 4.8 --- frontend/forge.config.ts | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/frontend/forge.config.ts b/frontend/forge.config.ts index d7b32402..99c3ab70 100644 --- a/frontend/forge.config.ts +++ b/frontend/forge.config.ts @@ -14,17 +14,30 @@ const config: ForgeConfig = { // deb/rpm makers below, and the runtime window icon from src/main.ts. icon: "assets/icon", extraResource: ["daemon", "assets/icon.png"], - // macOS signing + notarization — set CSC_LINK/CSC_KEY_PASSWORD and - // APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID in CI. + // macOS signing + notarization. Two paths are supported: + // - CI: set CSC_LINK/CSC_KEY_PASSWORD and + // APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID. + // - Local keychain: set APPLE_SIGNING_IDENTITY (a Developer ID Application + // identity in the login keychain) and AO_NOTARY_PROFILE (a notarytool + // keychain profile created with `notarytool store-credentials`). // See frontend/docs/desktop-release.md. - osxSign: process.env.CSC_LINK ? {} : undefined, - osxNotarize: process.env.APPLE_ID - ? { - appleId: process.env.APPLE_ID, - appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD!, - teamId: process.env.APPLE_TEAM_ID!, - } - : undefined, + osxSign: process.env.APPLE_SIGNING_IDENTITY + ? { identity: process.env.APPLE_SIGNING_IDENTITY } + : process.env.CSC_LINK + ? {} + : undefined, + osxNotarize: process.env.AO_NOTARY_PROFILE + ? ({ + tool: "notarytool", + keychainProfile: process.env.AO_NOTARY_PROFILE, + } as unknown as ForgeConfig["packagerConfig"]["osxNotarize"]) + : process.env.APPLE_ID + ? { + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD!, + teamId: process.env.APPLE_TEAM_ID!, + } + : undefined, }, rebuildConfig: {}, makers: [ From 6159bdb34f7cbf67982b5664ebeb18ebe2eff560 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 04:38:00 +0530 Subject: [PATCH 24/60] fix(daemon): default TERM so Finder-launched tmux attach works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Finder/Dock launch starts the supervisor under launchd with no controlling tty, so TERM is unset. The daemon inherits that, and its tmux attach client (spawned with env=nil, inheriting the daemon env) dies immediately with "open terminal failed: terminal does not support clear" — the orchestrator terminal pane never opens. Seed TERM=xterm-256color (what the renderer's xterm.js emulates) as the base of buildDaemonEnv, the same place PATH is reconstructed for the same class of "Finder launch lacks a terminal's env" bug. A real TERM from the shell/process env still wins. Co-Authored-By: Claude Opus 4.8 --- frontend/src/shared/shell-env.test.ts | 10 ++++++++++ frontend/src/shared/shell-env.ts | 8 +++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/src/shared/shell-env.test.ts b/frontend/src/shared/shell-env.test.ts index f2dcb018..f5a1c40a 100644 --- a/frontend/src/shared/shell-env.test.ts +++ b/frontend/src/shared/shell-env.test.ts @@ -82,6 +82,16 @@ describe("buildDaemonEnv", () => { expect(env.PATH?.split(":")).toContain(dir); } }); + + it("defaults TERM when neither shell nor process env sets it (Finder launch)", () => { + const env = buildDaemonEnv(minimalProcessEnv, null, {}); + expect(env.TERM).toBe("xterm-256color"); + }); + + it("lets a real TERM from the process env win over the default", () => { + const env = buildDaemonEnv({ ...minimalProcessEnv, TERM: "screen-256color" }, null, {}); + expect(env.TERM).toBe("screen-256color"); + }); }); describe("resolveShellPath", () => { diff --git a/frontend/src/shared/shell-env.ts b/frontend/src/shared/shell-env.ts index 768c670a..d2da45c6 100644 --- a/frontend/src/shared/shell-env.ts +++ b/frontend/src/shared/shell-env.ts @@ -65,12 +65,18 @@ export function withFallbackPath(currentPath: string | undefined): string { // Base = shell env, overlaid by processEnv so Electron/AO runtime vars win, then // PATH forced to the shell's PATH (with floor), then explicit overrides. +// +// TERM defaults to xterm-256color (what the renderer's xterm.js emulates): a +// Finder/Dock launch starts under launchd with no controlling tty, so TERM is +// unset, and the daemon's tmux attach client inherits that and dies with +// "open terminal failed: terminal does not support clear". Seeded as the base +// so a real TERM from the shell/process env still wins. export function buildDaemonEnv( processEnv: NodeJS.ProcessEnv, shellEnv: Record | null, overrides: Record, ): NodeJS.ProcessEnv { - const merged: NodeJS.ProcessEnv = { ...(shellEnv ?? {}), ...processEnv }; + const merged: NodeJS.ProcessEnv = { TERM: "xterm-256color", ...(shellEnv ?? {}), ...processEnv }; merged.PATH = withFallbackPath(shellEnv?.PATH ?? processEnv.PATH); return { ...merged, ...overrides }; } From 8e4e6daa85f86171e1fc4794831fe3140539ae92 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 05:16:08 +0530 Subject: [PATCH 25/60] docs(lifecycle): plan for save-on-close/restore-on-open sessions Captures the intended daemon lifecycle: on shutdown save every running session (worker and orchestrator) plus its gitignore-respecting uncommitted work to refs/ao/preserved/, then force-remove worktrees; on boot recreate worktrees, replay the preserved work, and restore all sessions. Reuses existing SQLite state, session_worktrees.preserved_ref, manager.Restore, and the /shutdown endpoint (no new file, migration, or route). Also gitignore the built daemon binary copied into frontend/daemon/. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + docs/plans/session-lifecycle-persistence.md | 213 ++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 docs/plans/session-lifecycle-persistence.md diff --git a/.gitignore b/.gitignore index 596f24d8..5b1ed1cd 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ builder-debug.yml # playwright artifacts frontend/test-results/ + +# built daemon binary copied into the frontend bundle dir +frontend/daemon/ diff --git a/docs/plans/session-lifecycle-persistence.md b/docs/plans/session-lifecycle-persistence.md new file mode 100644 index 00000000..7dc83393 --- /dev/null +++ b/docs/plans/session-lifecycle-persistence.md @@ -0,0 +1,213 @@ +# Plan: Save-on-Close / Restore-on-Open Session Lifecycle + +## Goal + +Make the intended lifecycle real and lean: on app close, save every running +session (worker AND orchestrator, no filtering) plus its uncommitted work, then +force-remove the worktrees. On app launch, recreate the worktrees, replay the +saved uncommitted work, and restore all sessions. The daemon already starts on +launch and shuts down + frees its port on quit; this plan fills the missing +save/restore middle. + +## Core architectural decisions (settled) + +1. **All save/restore logic lives in the daemon**, not the frontend. The daemon + owns the store, the gitworktree adapter, and the `git` binary. The frontend's + only new responsibility: call the existing `POST /shutdown` endpoint before + it kills the daemon, so the save runs gracefully (SIGTERM remains the + fallback and triggers the same daemon-side save path). +2. **The "last-stop manifest" is the existing SQLite state, not a new file.** + `ListAllSessions` already records id, kind (worker/orchestrator), harness, + `is_terminated`, and `Metadata{branch, workspacePath, agentSessionId, + prompt}`. The `session_worktrees` table already has a `preserved_ref` column + (migration 0009) that nothing currently writes. No manifest.json, no new + migration, no new format. The manifest is a query. +3. **Uncommitted work is captured as a git commit object pointed to by a ref** + `refs/ao/preserved/`. Reject the user's original + `refs/{worktree-path}/uncomit/` naming (worktree paths contain `/`, are not + valid single ref components, and are not stable identity). The session id is + the stable key the rest of the system already uses. +4. **Untracked files: respect `.gitignore`.** Build the preserve commit through + a temp index (`GIT_INDEX_FILE= git add -A; git write-tree; git + commit-tree`) so tracked + staged + new (non-ignored) files are captured, + side-effect-free, without mutating the working tree or the stash stack. + Ignored paths (`node_modules/`, build output, ignored `.env`) are skipped. + Log a one-line count of skipped ignored paths so it is never silent. (Chosen + over `git stash create`, which silently drops all untracked files, and over + `git stash push -u`, which mutates the worktree and the global stash stack.) +5. **Do not weaken the existing dirty-worktree refusal** used by interactive + `ao session kill` / `ao cleanup`. Add a separate `ForceDestroy` that the + shutdown path calls only AFTER the work is captured. Adding `--force` to the + shared remove path would silently destroy work in the interactive flows. + +## Global Constraints (binding — reviewers enforce verbatim) + +- App state resolves under `~/.ao` only (`AO_DATA_DIR`/`AO_RUN_FILE` + overridable). Never `~/Library/Application Support`. The manifest is the + existing SQLite DB at the configured data dir; preserve refs live in each + project repo's `.git`. +- Preserve ref name is exactly `refs/ao/preserved/`. +- Untracked capture respects `.gitignore` (no `-f`, no force-include). Skipped + ignored paths are logged with a count. +- No kind filtering anywhere in the save or restore loops: orchestrator and + worker sessions are both saved and both restored. +- Save is strictly capture-then-destroy, per session, with the DB write + committed before the worktree is removed (crash-safety invariant). +- Never delete a preserve ref except immediately after a successful clean + apply. A failed apply keeps the ref and leaves conflict markers for the agent. +- No new manifest file, no new migration, no new HTTP endpoint (reuse the + existing `POST /shutdown`). +- The existing single-session `POST /sessions/{id}/restore` endpoint and the + interactive dirty-refusal removal path stay behaviorally unchanged. +- No em dashes anywhere (prose, comments, commit messages). + +## Key files + +- `backend/internal/adapters/workspace/gitworktree/workspace.go` — Destroy, + Restore, isDirty, findWorktree (re-add logic lives here) +- `backend/internal/adapters/workspace/gitworktree/commands.go` — git arg + builders (`worktreeRemoveArgs` deliberately omits `--force`) +- `backend/internal/ports/outbound.go` — `Workspace` interface (~line 120) +- `backend/internal/session_manager/manager.go` — Kill (~411-446), Cleanup + (~556-588), Restore (~451), dirty-refusal translation +- `backend/internal/daemon/daemon.go` — boot/shutdown sequence (startSession + ~112, `srv.Run(ctx)` ~144) +- `backend/internal/storage/sqlite/store/session_store.go` — `ListAllSessions` + (~173) +- `backend/internal/storage/sqlite/store/session_worktree_store.go` — + `preserved_ref` CRUD (`UpsertSessionWorktree`) +- `backend/internal/domain/session.go`, `domain/project.go` — record + worktree + domain types +- `frontend/src/main.ts` — `before-quit` (~694-700), running.json port read + (~338) + +## Tasks (smallest coherent diff first; each ends with ONE runnable check) + +### Task 1 — `ForceDestroy` on the workspace port + gitworktree adapter +Add `ForceDestroy(ctx, info) error` to the `ports.Workspace` interface and the +gitworktree adapter. It runs `git worktree remove --force `, then prune, +then `os.RemoveAll` as a backstop. New arg builder in `commands.go`; leave the +existing safe `Destroy`/`worktreeRemoveArgs` untouched. Add the `ponytail:` +comment that ForceDestroy is only safe after the work is captured. +**Check:** Go test in `gitworktree` that creates a worktree, dirties it, calls +`ForceDestroy`, and asserts the path is gone and the worktree is deregistered. + +### Task 2 — `StashUncommitted` + `ApplyPreserved` on the gitworktree adapter +- `StashUncommitted(ctx, info) (ref string, err error)`: build the preserve + commit via a temp index that respects `.gitignore` + (`GIT_INDEX_FILE= git add -A` → `git write-tree` → `git commit-tree`), + point `refs/ao/preserved/` at it via `git update-ref`, return the ref name + (empty if the worktree is clean — nothing to preserve). Log count of ignored + paths skipped. +- `ApplyPreserved(ctx, info, ref) error`: apply the preserve commit's tree onto + the worktree (`git stash apply ` style, or `git read-tree`/checkout from + the commit). On clean success delete the ref (`git update-ref -d`); on + conflict, keep the ref, leave conflict markers, return a sentinel the caller + logs. +**Check:** Go test that round-trips a tracked edit AND a new non-ignored file +through StashUncommitted → ForceDestroy → re-add → ApplyPreserved and asserts +both reappear; and that a path matched by `.gitignore` does NOT reappear. + +### Task 3 — `SaveAndTeardownAll` + `RestoreAll` on the session manager +- `SaveAndTeardownAll(ctx)`: `ListAllSessions`; for each live (non-terminated) + session with a non-empty `Metadata.WorkspacePath`: `StashUncommitted` → + `UpsertSessionWorktree(preserved_ref=...)` (commit) → `MarkTerminated` + (reuse the LCM path Kill uses) → runtime teardown → `ForceDestroy`. Mirror + `Kill` but swap refuse-on-dirty for capture-then-force. No kind filter. +- `RestoreAll(ctx)`: `ListAllSessions`; for each terminated session that the + shutdown save actually processed: ensure worktree via the existing + `workspace.Restore`, `ApplyPreserved` if a preserve ref is recorded, then + `manager.Restore(ctx, id)`. Reuse existing `Restore`; do not duplicate its + argv/resume logic. + - **The "shutdown-saved" marker is the presence of a `session_worktrees` + row for that session.** Today nothing else writes `session_worktrees` + rows, so a row existing == "this session was saved by SaveAndTeardownAll". + A session the user killed earlier (already terminated when the save ran) + is skipped by the save and has no row, so RestoreAll skips it too. Do NOT + gate on `preserved_ref` being non-empty: a clean worktree at shutdown + writes a row with an empty `preserved_ref` and must still be restored. + No new column is needed (consistent with Task 6 leaving `state` alone). +**Check:** Go test with fakes asserting (a) save calls capture-then-force in +order and writes preserved_ref before ForceDestroy, (b) RestoreAll restores BOTH +a worker and an orchestrator, (c) a session the user killed before shutdown is +not resurrected. + +### Task 4 — Wire into daemon boot/shutdown (`daemon.go`) +- After `startSession` returns and before `srv.Run(ctx)`: call `RestoreAll` + (best-effort; log failures; never block boot). +- After `srv.Run(ctx)` returns and before the store closes: call + `SaveAndTeardownAll` with a fresh bounded context (not the cancelled `ctx`). +- Expose the manager (or a minimal `LifecycleSaver`/`LifecycleRestorer` seam) + from the wiring up to `Run`. +**Check:** Manual run documented in report — spawn a session, edit a tracked +file + add a new file, `POST /shutdown`; assert worktree removed and +`refs/ao/preserved/` exists; restart daemon; assert worktree re-created and +both edits reapplied. Plus `go build ./backend/...` green. + +### Task 5 — Frontend: call `/shutdown` before kill (`main.ts`) +In `before-quit`: `event.preventDefault()` once, `await fetch( +http://127.0.0.1:/shutdown, {method:'POST'})` with an ~8s bounded timeout +(port from the running.json the app already reads), then `killDaemon` + +`app.exit()`. Keep the `process.on('exit')` SIGTERM fallback intact. +**Check:** `cd frontend && ` green; manual: quit the app, daemon +log shows the save ran and exited cleanly (not just SIGTERM-killed). + +### Task 6 — Trim the over-built `session_worktrees.state` enum usage +No schema change. Ensure the save/restore code reads/writes only `preserved_ref` +and leaves `state` at its default; add `ponytail:` comments noting the enum is +unused multi-repo scaffolding. +**Check:** `go test ./backend/internal/storage/...` still green. + +## Edge cases the lean version must still handle + +1. Crash mid-shutdown: per-session capture-then-destroy with DB commit as the + commit point. Processed sessions recover via ref; unprocessed keep live + worktrees. No third lossy state. +2. User manually deleted a worktree dir: `workspace.Restore` re-adds from the + branch; stray non-worktree dir → it refuses, restore loop logs and skips. +3. Base branch moved: worktree re-added on the session's own branch; restores + to the agent's last state regardless of base. +4. Orchestrator vs workers: no kind filter in either loop. +5. Preserved diff conflicts on apply: keep the ref, leave conflict markers, + still relaunch the agent. Never delete the ref on failed apply. +6. Incomplete session (no branch/path): skipped on both save and restore. + +## Net change + +Added: 2 adapter methods (`ForceDestroy`, `StashUncommitted`/`ApplyPreserved`), +2 manager methods (`SaveAndTeardownAll`, `RestoreAll`), 2 daemon call sites, +1 frontend fetch. Reuses `ListAllSessions`, `session_worktrees.preserved_ref`, +`manager.Restore`, the LCM terminate path, and the existing `/shutdown` +endpoint. No new file, migration, format, or endpoint. + +## Build & verify commands (from repo root; see AGENTS.md for the full list) + +- `npm run lint` — backend `go test ./...` + golangci-lint v2.12.2 +- `cd backend && go build ./...` / `go test ./...` / `go test -race ./...` / + `go vet ./...` +- `npm run frontend:typecheck` — frontend TypeScript check (Task 5) +- Do NOT hand-edit `backend/internal/storage/sqlite/gen/*`. This plan adds no + new queries/migrations, so `npm run sqlc` should not be needed; if a task + finds it does need a new query, change `queries/*` and run `npm run sqlc`. +- This plan adds NO new HTTP routes, so the OpenAPI/`npm run api` flow and the + `internal/httpd` spec-drift tests should stay green untouched. If a reviewer + sees spec drift, a task wrongly added a route. + +## Starting point for the implementing session + +- Baseline: this plan and the cleanup are committed on `main` (the plan file + lives at `docs/plans/session-lifecycle-persistence.md`). Branch off `main` + as `feat/session-lifecycle-persistence`. +- The file:line references above are approximate (prefixed `~`). Verify each + with codegraph or grep before editing; the daemon is loopback-only and the + store is sqlc-generated, so confirm signatures rather than assuming. +- Use the `superpowers:subagent-driven-development` skill to execute: fresh + implementer subagent per task, task review (spec + quality) per task, then a + final whole-branch review. Subagents follow TDD. + +## Execution order + +Tasks are sequential where coupled: Task 2 shares the gitworktree adapter with +Task 1 (do 1 then 2, same package); Task 3 depends on 1 + 2; Task 4 depends on +3. Task 5 (frontend) and Task 6 (storage cleanup) are independent and can run +anytime. Suggested order: 1 → 2 → 3 → 4, then 5 and 6. From 975a6b3d0c27391dafc18621cb3085f8b1b92eff Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 05:16:08 +0530 Subject: [PATCH 26/60] chore(frontend): sync regenerated pnpm-lock and routeTree Working-tree regeneration of the pnpm lockfile and TanStack Router generated route tree. No hand edits; generated output only. Co-Authored-By: Claude Opus 4.8 --- frontend/pnpm-lock.yaml | 8148 ++++++++++++------------ frontend/src/renderer/routeTree.gen.ts | 311 +- 2 files changed, 4263 insertions(+), 4196 deletions(-) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 348392a2..d4f8a3da 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -1,49 +1,50 @@ -lockfileVersion: "9.0" +lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false importers: + .: dependencies: - "@radix-ui/react-dialog": + '@radix-ui/react-dialog': specifier: ^1.1.16 version: 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-slot": + '@radix-ui/react-slot': specifier: ^1.2.5 version: 1.2.5(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-tabs": + '@radix-ui/react-tabs': specifier: ^1.1.14 version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-tooltip": + '@radix-ui/react-tooltip': specifier: ^1.2.9 version: 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@tanstack/react-query": + '@tanstack/react-query': specifier: ^5.101.0 version: 5.101.0(react@19.2.7) - "@tanstack/react-router": + '@tanstack/react-router': specifier: ^1.170.15 version: 1.170.15(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@xterm/addon-canvas": + '@xterm/addon-canvas': specifier: ^0.7.0 version: 0.7.0(@xterm/xterm@5.5.0) - "@xterm/addon-fit": + '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) - "@xterm/addon-search": + '@xterm/addon-search': specifier: ^0.15.0 version: 0.15.0(@xterm/xterm@5.5.0) - "@xterm/addon-unicode11": + '@xterm/addon-unicode11': specifier: ^0.9.0 version: 0.9.0 - "@xterm/addon-web-links": + '@xterm/addon-web-links': specifier: ^0.11.0 version: 0.11.0(@xterm/xterm@5.5.0) - "@xterm/addon-webgl": + '@xterm/addon-webgl': specifier: ^0.19.0 version: 0.19.0 - "@xterm/xterm": + '@xterm/xterm': specifier: ^5.5.0 version: 5.5.0 class-variance-authority: @@ -58,6 +59,9 @@ importers: openapi-fetch: specifier: ^0.17.0 version: 0.17.0 + posthog-js: + specifier: ^1.390.2 + version: 1.393.0 radix-ui: specifier: ^1.5.0 version: 1.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -80,60 +84,63 @@ importers: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) devDependencies: - "@electron-forge/cli": + '@electron-forge/cli': specifier: ^7.8.0 version: 7.11.2(encoding@0.1.13) - "@electron-forge/maker-deb": + '@electron-forge/maker-base': specifier: ^7.8.0 version: 7.11.2 - "@electron-forge/maker-rpm": + '@electron-forge/maker-deb': specifier: ^7.8.0 version: 7.11.2 - "@electron-forge/maker-squirrel": + '@electron-forge/maker-rpm': specifier: ^7.8.0 version: 7.11.2 - "@electron-forge/maker-zip": + '@electron-forge/maker-zip': specifier: ^7.8.0 version: 7.11.2 - "@electron-forge/plugin-vite": + '@electron-forge/plugin-vite': specifier: ^7.8.0 version: 7.11.2 - "@electron-forge/publisher-github": + '@electron-forge/publisher-github': specifier: ^7.8.0 version: 7.11.2 - "@playwright/test": + '@playwright/test': specifier: ^1.60.0 version: 1.60.0 - "@tailwindcss/vite": + '@tailwindcss/vite': specifier: ^4.3.0 version: 4.3.0(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)) - "@tanstack/router-plugin": + '@tanstack/router-plugin': specifier: ^1.168.18 version: 1.168.18(@tanstack/react-router@1.170.15(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0))(webpack@5.107.2) - "@testing-library/jest-dom": + '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 - "@testing-library/react": + '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@testing-library/user-event": + '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) - "@types/react": + '@types/react': specifier: ^19.2.17 version: 19.2.17 - "@types/react-dom": + '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) - "@vitejs/plugin-react": + '@vitejs/plugin-react': specifier: ^6.0.2 version: 6.0.2(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)) + app-builder-lib: + specifier: ^26.15.3 + version: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) electron: specifier: ^33.0.0 version: 33.4.11 jsdom: specifier: ^29.1.1 - version: 29.1.1 + version: 29.1.1(@noble/hashes@2.2.0) openapi-typescript: specifier: ^7.13.0 version: 7.13.0(typescript@5.9.3) @@ -151,1661 +158,1478 @@ importers: version: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0) vitest: specifier: ^4.1.8 - version: 4.1.8(@types/node@25.9.2)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)) + version: 4.1.8(@types/node@25.9.2)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)) + optionalDependencies: + electron-installer-debian: + specifier: ^3.2.0 + version: 3.2.0 + electron-installer-redhat: + specifier: ^3.4.0 + version: 3.4.0 packages: - "@adobe/css-tools@4.5.0": - resolution: - { integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q== } - - "@asamuzakjp/css-color@5.1.11": - resolution: - { integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg== } - engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } - - "@asamuzakjp/dom-selector@7.1.1": - resolution: - { integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ== } - engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } - - "@asamuzakjp/generational-cache@1.0.1": - resolution: - { integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg== } - engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } - - "@asamuzakjp/nwsapi@2.3.9": - resolution: - { integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q== } - - "@babel/code-frame@7.29.7": - resolution: - { integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== } - engines: { node: ">=6.9.0" } - - "@babel/compat-data@7.29.7": - resolution: - { integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== } - engines: { node: ">=6.9.0" } - - "@babel/core@7.29.7": - resolution: - { integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== } - engines: { node: ">=6.9.0" } - - "@babel/generator@7.29.7": - resolution: - { integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ== } - engines: { node: ">=6.9.0" } - - "@babel/helper-compilation-targets@7.29.7": - resolution: - { integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g== } - engines: { node: ">=6.9.0" } - - "@babel/helper-globals@7.29.7": - resolution: - { integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA== } - engines: { node: ">=6.9.0" } - - "@babel/helper-module-imports@7.29.7": - resolution: - { integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g== } - engines: { node: ">=6.9.0" } - - "@babel/helper-module-transforms@7.29.7": - resolution: - { integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg== } - engines: { node: ">=6.9.0" } + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} peerDependencies: - "@babel/core": ^7.0.0 - - "@babel/helper-string-parser@7.29.7": - resolution: - { integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== } - engines: { node: ">=6.9.0" } - - "@babel/helper-validator-identifier@7.29.7": - resolution: - { integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== } - engines: { node: ">=6.9.0" } - - "@babel/helper-validator-option@7.29.7": - resolution: - { integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw== } - engines: { node: ">=6.9.0" } - - "@babel/helpers@7.29.7": - resolution: - { integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg== } - engines: { node: ">=6.9.0" } - - "@babel/parser@7.29.7": - resolution: - { integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== } - engines: { node: ">=6.0.0" } + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} hasBin: true - "@babel/runtime@7.29.7": - resolution: - { integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw== } - engines: { node: ">=6.9.0" } - - "@babel/template@7.29.7": - resolution: - { integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg== } - engines: { node: ">=6.9.0" } - - "@babel/traverse@7.29.7": - resolution: - { integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw== } - engines: { node: ">=6.9.0" } - - "@babel/types@7.29.7": - resolution: - { integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA== } - engines: { node: ">=6.9.0" } - - "@bramus/specificity@2.4.2": - resolution: - { integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw== } + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true - "@csstools/color-helpers@6.0.2": - resolution: - { integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q== } - engines: { node: ">=20.19.0" } + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} - "@csstools/css-calc@3.2.1": - resolution: - { integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg== } - engines: { node: ">=20.19.0" } + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} peerDependencies: - "@csstools/css-parser-algorithms": ^4.0.0 - "@csstools/css-tokenizer": ^4.0.0 + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 - "@csstools/css-color-parser@4.1.1": - resolution: - { integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g== } - engines: { node: ">=20.19.0" } + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} peerDependencies: - "@csstools/css-parser-algorithms": ^4.0.0 - "@csstools/css-tokenizer": ^4.0.0 + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 - "@csstools/css-parser-algorithms@4.0.0": - resolution: - { integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w== } - engines: { node: ">=20.19.0" } + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} peerDependencies: - "@csstools/css-tokenizer": ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 - "@csstools/css-syntax-patches-for-csstree@1.1.5": - resolution: - { integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A== } + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} peerDependencies: css-tree: ^3.2.1 peerDependenciesMeta: css-tree: optional: true - "@csstools/css-tokenizer@4.0.0": - resolution: - { integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA== } - engines: { node: ">=20.19.0" } + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} - "@electron-forge/cli@7.11.2": - resolution: - { integrity: sha512-c+C4ndLfHbxwZuCn9G8iT9wD/woLdaVkoSVjAIbj+0nJhi8UmiVsz/+Gxlj4cvhMRTzBMBxudstLU7RocMikfg== } - engines: { node: ">= 16.4.0" } + '@electron-forge/cli@7.11.2': + resolution: {integrity: sha512-c+C4ndLfHbxwZuCn9G8iT9wD/woLdaVkoSVjAIbj+0nJhi8UmiVsz/+Gxlj4cvhMRTzBMBxudstLU7RocMikfg==} + engines: {node: '>= 16.4.0'} hasBin: true - "@electron-forge/core-utils@7.11.2": - resolution: - { integrity: sha512-/Fpwo44an6ulUdq94co5OOcbRCohgYNci/E6eoZZuTO9f72X+PqJkMkghqkMX3iQ8Aq2QRLkGKFwrKWJNTjL7Q== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/core@7.11.2": - resolution: - { integrity: sha512-RbOvlCahSlYBkY1XFgD5QuoifZltEY3ezYGqJYnV1z6RiUK1DfUXwdidmclBLI9d6u8NNr9xWPv79LHVc9ZA3Q== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/maker-base@7.11.2": - resolution: - { integrity: sha512-9934zYu9WVdgCYQXvtS+eL1oyLagsY8JlWhZmoK8yWTYftSAydH7jb3seVpfy6n85SYmY/yjcAy2lvOTy5dUwA== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/maker-deb@7.11.2": - resolution: - { integrity: sha512-MYSdCTsqzKNmsmaq7CIFh2kJdBWUZ4njxnVGrIRClzueVITk5Kots3+eQo+e5QQLvXTVn2XTNDc2nYjvtBh+Mw== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/maker-rpm@7.11.2": - resolution: - { integrity: sha512-BEj/DcW6bSpmOyKUa3UsOgT7Hm3ZuP0Wa6OuQEunjxeCWn7yoDTDtjuYA0xRvzk+T4NCyDO3RBGjy6nYNSPU2Q== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/maker-squirrel@7.11.2": - resolution: - { integrity: sha512-4CILo57ZDEQH1mJxjhYCSXuv+WaU7oPq67KqiTLEUOEzmiPg9u9/z7FXE34H/Tn5aKWN3dy+ngAETzv6iERCGg== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/maker-zip@7.11.2": - resolution: - { integrity: sha512-FWnOm2MORX/nt8psnEtID3Vnt8Blby1NkzjU3KjXBPF9kave71C3lI8KbBbCeKKyTQ/S00i2FiglKdRWQ1WNTw== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/plugin-base@7.11.2": - resolution: - { integrity: sha512-tIFzEE2+D9NnCAn/rLwSkh8H59IqN+G973JNl7xmCzquO6qa7/veitZOQFGO79Zmmgkc8R/fmiCbh7LIdLS9Tg== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/plugin-vite@7.11.2": - resolution: - { integrity: sha512-QagRgjXfMBeyP+NkMdUMqke/E0ldfcBycjkgCb2FEH3VnS+Llk5RE2716H3quTuUtRhX2gdRuUDdLsstHFuGWg== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/publisher-base@7.11.2": - resolution: - { integrity: sha512-YwK4ZF3+uW7PBEV/ho59NVTriP3fCahskORrztUaFIdG0QP3hqMsfmo01euv98FDsBEW9UXo7/EW8t5jpmYZ0Q== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/publisher-github@7.11.2": - resolution: - { integrity: sha512-1kandHpGPRg2+Lfo6AyI6DtKVMmrc0yyOdJJmo1A7eJO6U8icMGrypSPwOIiTsN6OYJds/vZBzFQ6Vs9rvRsVg== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/shared-types@7.11.2": - resolution: - { integrity: sha512-Tcles7y74xy3jN5dEC+Pt1duJYk4c7W2xu98tjWW8RewmfKD2uHkie6I1I3yifPFZXZ/QfTlaFOOoKIQ9ENZjg== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/template-base@7.11.2": - resolution: - { integrity: sha512-l10I+XZRbbxFGiDLMnuXmlOppmLYmimKj6FWjEGUvft4VJFXW2BIDrLIugIGdM1nbrl/0aYjen2xRg0nZlcWzg== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/template-vite-typescript@7.11.2": - resolution: - { integrity: sha512-QvvdmO9Gdv+3aISI9+bBLKPBTyKaucs6HhXxz+IDALcdykIL9wVN0/BrWuwwgbwuw4BiJTyXGSPNXuJ+EWnP6g== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/template-vite@7.11.2": - resolution: - { integrity: sha512-yFSDSu3IdyNpgLXzrwODSUyaWniHRSZI82gwcXdnJLx7D7DIDLtbx6KzEoy7QBmWZRULO3F7rLsYG+Ur7orvyA== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/template-webpack-typescript@7.11.2": - resolution: - { integrity: sha512-2lwK+OrCeZgYM8WqsUXJzk94rdF0z/kA7WnAf79U3COEmAAMcFIwJtwF8c/n+52UecP3yrEE70LIGmM1sjGZJQ== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/template-webpack@7.11.2": - resolution: - { integrity: sha512-JjG8XIZctrSZvTlii7Hqvt/pHDKigRk4PoLTQCs1TiT05ZWsn40itBm8cbja3L7bfm0ccDd3JTWWOl2G7PhlmA== } - engines: { node: ">= 16.4.0" } - - "@electron-forge/tracer@7.11.2": - resolution: - { integrity: sha512-U8j5Hyj2Zt7I5PciJvPJfmEv69Gb/Da9v+k655z3Jj1cuY0UnToEJ61IhXrzlTYqo+jUKC+fgAjDJ6vltJTS0A== } - engines: { node: ">= 14.17.5" } - - "@electron/asar@3.4.1": - resolution: - { integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA== } - engines: { node: ">=10.12.0" } + '@electron-forge/core-utils@7.11.2': + resolution: {integrity: sha512-/Fpwo44an6ulUdq94co5OOcbRCohgYNci/E6eoZZuTO9f72X+PqJkMkghqkMX3iQ8Aq2QRLkGKFwrKWJNTjL7Q==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/core@7.11.2': + resolution: {integrity: sha512-RbOvlCahSlYBkY1XFgD5QuoifZltEY3ezYGqJYnV1z6RiUK1DfUXwdidmclBLI9d6u8NNr9xWPv79LHVc9ZA3Q==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/maker-base@7.11.2': + resolution: {integrity: sha512-9934zYu9WVdgCYQXvtS+eL1oyLagsY8JlWhZmoK8yWTYftSAydH7jb3seVpfy6n85SYmY/yjcAy2lvOTy5dUwA==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/maker-deb@7.11.2': + resolution: {integrity: sha512-MYSdCTsqzKNmsmaq7CIFh2kJdBWUZ4njxnVGrIRClzueVITk5Kots3+eQo+e5QQLvXTVn2XTNDc2nYjvtBh+Mw==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/maker-rpm@7.11.2': + resolution: {integrity: sha512-BEj/DcW6bSpmOyKUa3UsOgT7Hm3ZuP0Wa6OuQEunjxeCWn7yoDTDtjuYA0xRvzk+T4NCyDO3RBGjy6nYNSPU2Q==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/maker-zip@7.11.2': + resolution: {integrity: sha512-FWnOm2MORX/nt8psnEtID3Vnt8Blby1NkzjU3KjXBPF9kave71C3lI8KbBbCeKKyTQ/S00i2FiglKdRWQ1WNTw==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/plugin-base@7.11.2': + resolution: {integrity: sha512-tIFzEE2+D9NnCAn/rLwSkh8H59IqN+G973JNl7xmCzquO6qa7/veitZOQFGO79Zmmgkc8R/fmiCbh7LIdLS9Tg==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/plugin-vite@7.11.2': + resolution: {integrity: sha512-QagRgjXfMBeyP+NkMdUMqke/E0ldfcBycjkgCb2FEH3VnS+Llk5RE2716H3quTuUtRhX2gdRuUDdLsstHFuGWg==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/publisher-base@7.11.2': + resolution: {integrity: sha512-YwK4ZF3+uW7PBEV/ho59NVTriP3fCahskORrztUaFIdG0QP3hqMsfmo01euv98FDsBEW9UXo7/EW8t5jpmYZ0Q==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/publisher-github@7.11.2': + resolution: {integrity: sha512-1kandHpGPRg2+Lfo6AyI6DtKVMmrc0yyOdJJmo1A7eJO6U8icMGrypSPwOIiTsN6OYJds/vZBzFQ6Vs9rvRsVg==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/shared-types@7.11.2': + resolution: {integrity: sha512-Tcles7y74xy3jN5dEC+Pt1duJYk4c7W2xu98tjWW8RewmfKD2uHkie6I1I3yifPFZXZ/QfTlaFOOoKIQ9ENZjg==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/template-base@7.11.2': + resolution: {integrity: sha512-l10I+XZRbbxFGiDLMnuXmlOppmLYmimKj6FWjEGUvft4VJFXW2BIDrLIugIGdM1nbrl/0aYjen2xRg0nZlcWzg==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/template-vite-typescript@7.11.2': + resolution: {integrity: sha512-QvvdmO9Gdv+3aISI9+bBLKPBTyKaucs6HhXxz+IDALcdykIL9wVN0/BrWuwwgbwuw4BiJTyXGSPNXuJ+EWnP6g==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/template-vite@7.11.2': + resolution: {integrity: sha512-yFSDSu3IdyNpgLXzrwODSUyaWniHRSZI82gwcXdnJLx7D7DIDLtbx6KzEoy7QBmWZRULO3F7rLsYG+Ur7orvyA==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/template-webpack-typescript@7.11.2': + resolution: {integrity: sha512-2lwK+OrCeZgYM8WqsUXJzk94rdF0z/kA7WnAf79U3COEmAAMcFIwJtwF8c/n+52UecP3yrEE70LIGmM1sjGZJQ==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/template-webpack@7.11.2': + resolution: {integrity: sha512-JjG8XIZctrSZvTlii7Hqvt/pHDKigRk4PoLTQCs1TiT05ZWsn40itBm8cbja3L7bfm0ccDd3JTWWOl2G7PhlmA==} + engines: {node: '>= 16.4.0'} + + '@electron-forge/tracer@7.11.2': + resolution: {integrity: sha512-U8j5Hyj2Zt7I5PciJvPJfmEv69Gb/Da9v+k655z3Jj1cuY0UnToEJ61IhXrzlTYqo+jUKC+fgAjDJ6vltJTS0A==} + engines: {node: '>= 14.17.5'} + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} hasBin: true - "@electron/get@2.0.3": - resolution: - { integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ== } - engines: { node: ">=12" } - - "@electron/get@3.1.0": - resolution: - { integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ== } - engines: { node: ">=14" } - - "@electron/node-gyp@https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2": - resolution: - { - gitHosted: true, - tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2, - } + '@electron/fuses@1.8.0': + resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} + hasBin: true + + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + + '@electron/get@3.1.0': + resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} + engines: {node: '>=14'} + + '@electron/node-gyp@https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2': + resolution: {tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2} version: 10.2.0-electron.1 - engines: { node: ">=12.13.0" } + engines: {node: '>=12.13.0'} hasBin: true - "@electron/notarize@2.5.0": - resolution: - { integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A== } - engines: { node: ">= 10.0.0" } + '@electron/notarize@2.5.0': + resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} + engines: {node: '>= 10.0.0'} - "@electron/osx-sign@1.3.3": - resolution: - { integrity: sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg== } - engines: { node: ">=12.0.0" } + '@electron/osx-sign@1.3.3': + resolution: {integrity: sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==} + engines: {node: '>=12.0.0'} hasBin: true - "@electron/packager@18.4.4": - resolution: - { integrity: sha512-fTUCmgL25WXTcFpM1M72VmFP8w3E4d+KNzWxmTDRpvwkfn/S206MAtM2cy0GF78KS9AwASMOUmlOIzCHeNxcGQ== } - engines: { node: ">= 16.13.0" } + '@electron/packager@18.4.4': + resolution: {integrity: sha512-fTUCmgL25WXTcFpM1M72VmFP8w3E4d+KNzWxmTDRpvwkfn/S206MAtM2cy0GF78KS9AwASMOUmlOIzCHeNxcGQ==} + engines: {node: '>= 16.13.0'} hasBin: true - "@electron/rebuild@3.7.2": - resolution: - { integrity: sha512-19/KbIR/DAxbsCkiaGMXIdPnMCJLkcf8AvGnduJtWBs/CBwiAjY1apCqOLVxrXg+rtXFCngbXhBanWjxLUt1Mg== } - engines: { node: ">=12.13.0" } + '@electron/rebuild@3.7.2': + resolution: {integrity: sha512-19/KbIR/DAxbsCkiaGMXIdPnMCJLkcf8AvGnduJtWBs/CBwiAjY1apCqOLVxrXg+rtXFCngbXhBanWjxLUt1Mg==} + engines: {node: '>=12.13.0'} hasBin: true - "@electron/universal@2.0.3": - resolution: - { integrity: sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g== } - engines: { node: ">=16.4" } + '@electron/rebuild@4.0.4': + resolution: {integrity: sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/universal@2.0.3': + resolution: {integrity: sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==} + engines: {node: '>=16.4'} - "@electron/windows-sign@1.2.2": - resolution: - { integrity: sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ== } - engines: { node: ">=14.14" } + '@electron/windows-sign@1.2.2': + resolution: {integrity: sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==} + engines: {node: '>=14.14'} hasBin: true - "@emnapi/core@1.10.0": - resolution: - { integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw== } + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - "@emnapi/runtime@1.10.0": - resolution: - { integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA== } + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - "@emnapi/wasi-threads@1.2.1": - resolution: - { integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== } + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - "@exodus/bytes@1.15.1": - resolution: - { integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q== } - engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: - "@noble/hashes": ^1.8.0 || ^2.0.0 + '@noble/hashes': ^1.8.0 || ^2.0.0 peerDependenciesMeta: - "@noble/hashes": + '@noble/hashes': optional: true - "@floating-ui/core@1.7.5": - resolution: - { integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ== } + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - "@floating-ui/dom@1.7.6": - resolution: - { integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ== } + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - "@floating-ui/react-dom@2.1.8": - resolution: - { integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A== } + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} peerDependencies: - react: ">=16.8.0" - react-dom: ">=16.8.0" - - "@floating-ui/utils@0.2.11": - resolution: - { integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg== } - - "@gar/promisify@1.1.3": - resolution: - { integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== } - - "@inquirer/checkbox@3.0.1": - resolution: - { integrity: sha512-0hm2nrToWUdD6/UHnel/UKGdk1//ke5zGUpHIvk5ZWmaKezlGxZkOJXNSWsdxO/rEqTkbB3lNC2J6nBElV2aAQ== } - engines: { node: ">=18" } - - "@inquirer/confirm@4.0.1": - resolution: - { integrity: sha512-46yL28o2NJ9doViqOy0VDcoTzng7rAb6yPQKU7VDLqkmbCaH4JqK4yk4XqlzNWy9PVC5pG1ZUXPBQv+VqnYs2w== } - engines: { node: ">=18" } - - "@inquirer/core@9.2.1": - resolution: - { integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg== } - engines: { node: ">=18" } - - "@inquirer/editor@3.0.1": - resolution: - { integrity: sha512-VA96GPFaSOVudjKFraokEEmUQg/Lub6OXvbIEZU1SDCmBzRkHGhxoFAVaF30nyiB4m5cEbDgiI2QRacXZ2hw9Q== } - engines: { node: ">=18" } - - "@inquirer/expand@3.0.1": - resolution: - { integrity: sha512-ToG8d6RIbnVpbdPdiN7BCxZGiHOTomOX94C2FaT5KOHupV40tKEDozp12res6cMIfRKrXLJyexAZhWVHgbALSQ== } - engines: { node: ">=18" } - - "@inquirer/figures@1.0.15": - resolution: - { integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g== } - engines: { node: ">=18" } - - "@inquirer/input@3.0.1": - resolution: - { integrity: sha512-BDuPBmpvi8eMCxqC5iacloWqv+5tQSJlUafYWUe31ow1BVXjW2a5qe3dh4X/Z25Wp22RwvcaLCc2siHobEOfzg== } - engines: { node: ">=18" } - - "@inquirer/number@2.0.1": - resolution: - { integrity: sha512-QpR8jPhRjSmlr/mD2cw3IR8HRO7lSVOnqUvQa8scv1Lsr3xoAMMworcYW3J13z3ppjBFBD2ef1Ci6AE5Qn8goQ== } - engines: { node: ">=18" } - - "@inquirer/password@3.0.1": - resolution: - { integrity: sha512-haoeEPUisD1NeE2IanLOiFr4wcTXGWrBOyAyPZi1FfLJuXOzNmxCJPgUrGYKVh+Y8hfGJenIfz5Wb/DkE9KkMQ== } - engines: { node: ">=18" } - - "@inquirer/prompts@6.0.1": - resolution: - { integrity: sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A== } - engines: { node: ">=18" } - - "@inquirer/rawlist@3.0.1": - resolution: - { integrity: sha512-VgRtFIwZInUzTiPLSfDXK5jLrnpkuSOh1ctfaoygKAdPqjcjKYmGh6sCY1pb0aGnCGsmhUxoqLDUAU0ud+lGXQ== } - engines: { node: ">=18" } - - "@inquirer/search@2.0.1": - resolution: - { integrity: sha512-r5hBKZk3g5MkIzLVoSgE4evypGqtOannnB3PKTG9NRZxyFRKcfzrdxXXPcoJQsxJPzvdSU2Rn7pB7lw0GCmGAg== } - engines: { node: ">=18" } - - "@inquirer/select@3.0.1": - resolution: - { integrity: sha512-lUDGUxPhdWMkN/fHy1Lk7pF3nK1fh/gqeyWXmctefhxLYxlDsc7vsPBEpxrfVGDsVdyYJsiJoD4bJ1b623cV1Q== } - engines: { node: ">=18" } - - "@inquirer/type@1.5.5": - resolution: - { integrity: sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA== } - engines: { node: ">=18" } - - "@inquirer/type@2.0.0": - resolution: - { integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag== } - engines: { node: ">=18" } - - "@jridgewell/gen-mapping@0.3.13": - resolution: - { integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== } - - "@jridgewell/remapping@2.3.5": - resolution: - { integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== } - - "@jridgewell/resolve-uri@3.1.2": - resolution: - { integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== } - engines: { node: ">=6.0.0" } - - "@jridgewell/source-map@0.3.11": - resolution: - { integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA== } - - "@jridgewell/sourcemap-codec@1.5.5": - resolution: - { integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== } - - "@jridgewell/trace-mapping@0.3.31": - resolution: - { integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== } - - "@listr2/prompt-adapter-inquirer@2.0.22": - resolution: - { integrity: sha512-hV36ZoY+xKL6pYOt1nPNnkciFkn89KZwqLhAFzJvYysAvL5uBQdiADZx/8bIDXIukzzwG0QlPYolgMzQUtKgpQ== } - engines: { node: ">=18.0.0" } + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@gar/promisify@1.1.3': + resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + + '@inquirer/checkbox@3.0.1': + resolution: {integrity: sha512-0hm2nrToWUdD6/UHnel/UKGdk1//ke5zGUpHIvk5ZWmaKezlGxZkOJXNSWsdxO/rEqTkbB3lNC2J6nBElV2aAQ==} + engines: {node: '>=18'} + + '@inquirer/confirm@4.0.1': + resolution: {integrity: sha512-46yL28o2NJ9doViqOy0VDcoTzng7rAb6yPQKU7VDLqkmbCaH4JqK4yk4XqlzNWy9PVC5pG1ZUXPBQv+VqnYs2w==} + engines: {node: '>=18'} + + '@inquirer/core@9.2.1': + resolution: {integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==} + engines: {node: '>=18'} + + '@inquirer/editor@3.0.1': + resolution: {integrity: sha512-VA96GPFaSOVudjKFraokEEmUQg/Lub6OXvbIEZU1SDCmBzRkHGhxoFAVaF30nyiB4m5cEbDgiI2QRacXZ2hw9Q==} + engines: {node: '>=18'} + + '@inquirer/expand@3.0.1': + resolution: {integrity: sha512-ToG8d6RIbnVpbdPdiN7BCxZGiHOTomOX94C2FaT5KOHupV40tKEDozp12res6cMIfRKrXLJyexAZhWVHgbALSQ==} + engines: {node: '>=18'} + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@3.0.1': + resolution: {integrity: sha512-BDuPBmpvi8eMCxqC5iacloWqv+5tQSJlUafYWUe31ow1BVXjW2a5qe3dh4X/Z25Wp22RwvcaLCc2siHobEOfzg==} + engines: {node: '>=18'} + + '@inquirer/number@2.0.1': + resolution: {integrity: sha512-QpR8jPhRjSmlr/mD2cw3IR8HRO7lSVOnqUvQa8scv1Lsr3xoAMMworcYW3J13z3ppjBFBD2ef1Ci6AE5Qn8goQ==} + engines: {node: '>=18'} + + '@inquirer/password@3.0.1': + resolution: {integrity: sha512-haoeEPUisD1NeE2IanLOiFr4wcTXGWrBOyAyPZi1FfLJuXOzNmxCJPgUrGYKVh+Y8hfGJenIfz5Wb/DkE9KkMQ==} + engines: {node: '>=18'} + + '@inquirer/prompts@6.0.1': + resolution: {integrity: sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==} + engines: {node: '>=18'} + + '@inquirer/rawlist@3.0.1': + resolution: {integrity: sha512-VgRtFIwZInUzTiPLSfDXK5jLrnpkuSOh1ctfaoygKAdPqjcjKYmGh6sCY1pb0aGnCGsmhUxoqLDUAU0ud+lGXQ==} + engines: {node: '>=18'} + + '@inquirer/search@2.0.1': + resolution: {integrity: sha512-r5hBKZk3g5MkIzLVoSgE4evypGqtOannnB3PKTG9NRZxyFRKcfzrdxXXPcoJQsxJPzvdSU2Rn7pB7lw0GCmGAg==} + engines: {node: '>=18'} + + '@inquirer/select@3.0.1': + resolution: {integrity: sha512-lUDGUxPhdWMkN/fHy1Lk7pF3nK1fh/gqeyWXmctefhxLYxlDsc7vsPBEpxrfVGDsVdyYJsiJoD4bJ1b623cV1Q==} + engines: {node: '>=18'} + + '@inquirer/type@1.5.5': + resolution: {integrity: sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==} + engines: {node: '>=18'} + + '@inquirer/type@2.0.0': + resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} + engines: {node: '>=18'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@listr2/prompt-adapter-inquirer@2.0.22': + resolution: {integrity: sha512-hV36ZoY+xKL6pYOt1nPNnkciFkn89KZwqLhAFzJvYysAvL5uBQdiADZx/8bIDXIukzzwG0QlPYolgMzQUtKgpQ==} + engines: {node: '>=18.0.0'} peerDependencies: - "@inquirer/prompts": ">= 3 < 8" + '@inquirer/prompts': '>= 3 < 8' - "@malept/cross-spawn-promise@1.1.1": - resolution: - { integrity: sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ== } - engines: { node: ">= 10" } + '@malept/cross-spawn-promise@1.1.1': + resolution: {integrity: sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==} + engines: {node: '>= 10'} - "@malept/cross-spawn-promise@2.0.0": - resolution: - { integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg== } - engines: { node: ">= 12.13.0" } + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} - "@napi-rs/wasm-runtime@1.1.4": - resolution: - { integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow== } + '@malept/flatpak-bundler@0.4.0': + resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} + engines: {node: '>= 10.0.0'} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: - "@emnapi/core": ^1.7.1 - "@emnapi/runtime": ^1.7.1 - - "@nodelib/fs.scandir@2.1.5": - resolution: - { integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== } - engines: { node: ">= 8" } - - "@nodelib/fs.stat@2.0.5": - resolution: - { integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== } - engines: { node: ">= 8" } - - "@nodelib/fs.walk@1.2.8": - resolution: - { integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== } - engines: { node: ">= 8" } - - "@npmcli/fs@2.1.2": - resolution: - { integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ== } - engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } - - "@npmcli/move-file@2.0.1": - resolution: - { integrity: sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ== } - engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@npmcli/fs@2.1.2': + resolution: {integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + '@npmcli/move-file@2.0.1': + resolution: {integrity: sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This functionality has been moved to @npmcli/fs - "@octokit/auth-token@4.0.0": - resolution: - { integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA== } - engines: { node: ">= 18" } - - "@octokit/core@5.2.2": - resolution: - { integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg== } - engines: { node: ">= 18" } - - "@octokit/endpoint@9.0.6": - resolution: - { integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw== } - engines: { node: ">= 18" } - - "@octokit/graphql@7.1.1": - resolution: - { integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g== } - engines: { node: ">= 18" } - - "@octokit/openapi-types@12.11.0": - resolution: - { integrity: sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ== } - - "@octokit/openapi-types@24.2.0": - resolution: - { integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg== } - - "@octokit/plugin-paginate-rest@11.4.4-cjs.2": - resolution: - { integrity: sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw== } - engines: { node: ">= 18" } + '@octokit/auth-token@4.0.0': + resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} + engines: {node: '>= 18'} + + '@octokit/core@5.2.2': + resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==} + engines: {node: '>= 18'} + + '@octokit/endpoint@9.0.6': + resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} + engines: {node: '>= 18'} + + '@octokit/graphql@7.1.1': + resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==} + engines: {node: '>= 18'} + + '@octokit/openapi-types@12.11.0': + resolution: {integrity: sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==} + + '@octokit/openapi-types@24.2.0': + resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + + '@octokit/plugin-paginate-rest@11.4.4-cjs.2': + resolution: {integrity: sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==} + engines: {node: '>= 18'} peerDependencies: - "@octokit/core": "5" + '@octokit/core': '5' - "@octokit/plugin-request-log@4.0.1": - resolution: - { integrity: sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA== } - engines: { node: ">= 18" } + '@octokit/plugin-request-log@4.0.1': + resolution: {integrity: sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==} + engines: {node: '>= 18'} peerDependencies: - "@octokit/core": "5" + '@octokit/core': '5' - "@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1": - resolution: - { integrity: sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ== } - engines: { node: ">= 18" } + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1': + resolution: {integrity: sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==} + engines: {node: '>= 18'} peerDependencies: - "@octokit/core": ^5 + '@octokit/core': ^5 - "@octokit/plugin-retry@6.1.0": - resolution: - { integrity: sha512-WrO3bvq4E1Xh1r2mT9w6SDFg01gFmP81nIG77+p/MqW1JeXXgL++6umim3t6x0Zj5pZm3rXAN+0HEjmmdhIRig== } - engines: { node: ">= 18" } + '@octokit/plugin-retry@6.1.0': + resolution: {integrity: sha512-WrO3bvq4E1Xh1r2mT9w6SDFg01gFmP81nIG77+p/MqW1JeXXgL++6umim3t6x0Zj5pZm3rXAN+0HEjmmdhIRig==} + engines: {node: '>= 18'} peerDependencies: - "@octokit/core": "5" - - "@octokit/request-error@5.1.1": - resolution: - { integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g== } - engines: { node: ">= 18" } - - "@octokit/request@8.4.1": - resolution: - { integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw== } - engines: { node: ">= 18" } - - "@octokit/rest@20.1.2": - resolution: - { integrity: sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA== } - engines: { node: ">= 18" } - - "@octokit/types@13.10.0": - resolution: - { integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA== } - - "@octokit/types@6.41.0": - resolution: - { integrity: sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg== } - - "@oxc-project/types@0.133.0": - resolution: - { integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA== } - - "@playwright/test@1.60.0": - resolution: - { integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag== } - engines: { node: ">=18" } + '@octokit/core': '5' + + '@octokit/request-error@5.1.1': + resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} + engines: {node: '>= 18'} + + '@octokit/request@8.4.1': + resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} + engines: {node: '>= 18'} + + '@octokit/rest@20.1.2': + resolution: {integrity: sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==} + engines: {node: '>= 18'} + + '@octokit/types@13.10.0': + resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + + '@octokit/types@6.41.0': + resolution: {integrity: sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==} + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@peculiar/asn1-schema@2.8.0': + resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} + + '@peculiar/json-schema@1.1.12': + resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} + engines: {node: '>=8.0.0'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/webcrypto@1.7.1': + resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} + engines: {node: '>=14.18.0'} + + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} hasBin: true - "@radix-ui/number@1.1.2": - resolution: - { integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig== } + '@posthog/core@1.37.1': + resolution: {integrity: sha512-KRBuxF/XBm3tNpqWlXpWE82XxsYsJb0jSyEic14LMXMvqDv5iApK1jfV0+seikDb9SpPs3tPkWUfHdwaUtFBtQ==} + + '@posthog/types@1.391.0': + resolution: {integrity: sha512-oJ6jkqVMq+T4ax9F0rUllJc0KHpSgpaMwTNYWkE70iBiyXDVyhcNBmYnNKzSODgpzsaQNI6VfK8JrRYbkSJZZw==} + + '@radix-ui/number@1.1.2': + resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} - "@radix-ui/primitive@1.1.4": - resolution: - { integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ== } + '@radix-ui/primitive@1.1.4': + resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} - "@radix-ui/react-accessible-icon@1.1.9": - resolution: - { integrity: sha512-5W9KzJz/3DeYbGJHbZv8Q6AkxMOKUmALfc+PRg9dWwJZMk6zD37Sz8sZrF7UD6CBkiJvn7dNeRzn5G7XiCMyig== } + '@radix-ui/react-accessible-icon@1.1.9': + resolution: {integrity: sha512-5W9KzJz/3DeYbGJHbZv8Q6AkxMOKUmALfc+PRg9dWwJZMk6zD37Sz8sZrF7UD6CBkiJvn7dNeRzn5G7XiCMyig==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-accordion@1.2.13": - resolution: - { integrity: sha512-xITxBB2p5m5tAe7M0F95kb4uAh7jSIKGlExMEm93HlW+XxZHV2eXFbPWLktd4JhRiwcnXNbO7iekcrbZy6ZCvA== } + '@radix-ui/react-accordion@1.2.13': + resolution: {integrity: sha512-xITxBB2p5m5tAe7M0F95kb4uAh7jSIKGlExMEm93HlW+XxZHV2eXFbPWLktd4JhRiwcnXNbO7iekcrbZy6ZCvA==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-alert-dialog@1.1.16": - resolution: - { integrity: sha512-vPaIgo0mxYlvcFaM9jB2Uot9TjGXMuAPEvrc6BOLeV+I5U8s1dkIoouYaa6lmSfc5SPMo5x5djOTOTvaigdGMQ== } + '@radix-ui/react-alert-dialog@1.1.16': + resolution: {integrity: sha512-vPaIgo0mxYlvcFaM9jB2Uot9TjGXMuAPEvrc6BOLeV+I5U8s1dkIoouYaa6lmSfc5SPMo5x5djOTOTvaigdGMQ==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-arrow@1.1.9": - resolution: - { integrity: sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig== } + '@radix-ui/react-arrow@1.1.9': + resolution: {integrity: sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-aspect-ratio@1.1.9": - resolution: - { integrity: sha512-Xy+Dpxt/5n9rVTdPrNFmf8GwG1NlT1pzCF/z1MgOGZMLZWdWl+km+ZRWGQAPEhbkzSwYEsfYmTca8NhUtVxqnw== } + '@radix-ui/react-aspect-ratio@1.1.9': + resolution: {integrity: sha512-Xy+Dpxt/5n9rVTdPrNFmf8GwG1NlT1pzCF/z1MgOGZMLZWdWl+km+ZRWGQAPEhbkzSwYEsfYmTca8NhUtVxqnw==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-avatar@1.1.12": - resolution: - { integrity: sha512-NQCQyWC7QrDPhjMn8hUqFeU0lUrprIgm1AyMgLbzuQJibNnatdc3SSMo3/UGFu/eUkJUU1cEcKCnyhXTQzq6tA== } + '@radix-ui/react-avatar@1.1.12': + resolution: {integrity: sha512-NQCQyWC7QrDPhjMn8hUqFeU0lUrprIgm1AyMgLbzuQJibNnatdc3SSMo3/UGFu/eUkJUU1cEcKCnyhXTQzq6tA==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-checkbox@1.3.4": - resolution: - { integrity: sha512-m3JmIOAX5ZzZ6VPjxEU2dbTOhoHi0nT5riwcDwe8idocsWf4a5DXJLDtZ6LfJwMBx7W+A2b7kp2TgPEKtaiF6A== } + '@radix-ui/react-checkbox@1.3.4': + resolution: {integrity: sha512-m3JmIOAX5ZzZ6VPjxEU2dbTOhoHi0nT5riwcDwe8idocsWf4a5DXJLDtZ6LfJwMBx7W+A2b7kp2TgPEKtaiF6A==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-collapsible@1.1.13": - resolution: - { integrity: sha512-F0s8+p2XNpfc3k02zBfB0jPWbkHVG162+p7BdUMyJ2308QMqZ+oaclX+FAzKFovgL5OqRU+Rvy6f/vbdlJVaqA== } + '@radix-ui/react-collapsible@1.1.13': + resolution: {integrity: sha512-F0s8+p2XNpfc3k02zBfB0jPWbkHVG162+p7BdUMyJ2308QMqZ+oaclX+FAzKFovgL5OqRU+Rvy6f/vbdlJVaqA==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-collection@1.1.9": - resolution: - { integrity: sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ== } + '@radix-ui/react-collection@1.1.9': + resolution: {integrity: sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-compose-refs@1.1.3": - resolution: - { integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA== } + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-context-menu@2.3.0": - resolution: - { integrity: sha512-d7CouXhAW+CGmFOqmB+IEvd3E9GcaqfgvfjCc3hfulp2pkaUCEVEGa0SN5nNWYA+IvQ6g1Pt+S5dpNn1AoY9hg== } + '@radix-ui/react-context-menu@2.3.0': + resolution: {integrity: sha512-d7CouXhAW+CGmFOqmB+IEvd3E9GcaqfgvfjCc3hfulp2pkaUCEVEGa0SN5nNWYA+IvQ6g1Pt+S5dpNn1AoY9hg==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-context@1.1.4": - resolution: - { integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg== } + '@radix-ui/react-context@1.1.4': + resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-dialog@1.1.16": - resolution: - { integrity: sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw== } + '@radix-ui/react-dialog@1.1.16': + resolution: {integrity: sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-direction@1.1.2": - resolution: - { integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA== } + '@radix-ui/react-direction@1.1.2': + resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-dismissable-layer@1.1.12": - resolution: - { integrity: sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg== } + '@radix-ui/react-dismissable-layer@1.1.12': + resolution: {integrity: sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-dropdown-menu@2.1.17": - resolution: - { integrity: sha512-S6b3Jm57sY5EdDyOMLkacbB0qMnKhy1RCKZCt795ZkmtUOAvojYIZ5p7dXHIh5Cyr3jCLLI5/g64V3FKLudZmw== } + '@radix-ui/react-dropdown-menu@2.1.17': + resolution: {integrity: sha512-S6b3Jm57sY5EdDyOMLkacbB0qMnKhy1RCKZCt795ZkmtUOAvojYIZ5p7dXHIh5Cyr3jCLLI5/g64V3FKLudZmw==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-focus-guards@1.1.4": - resolution: - { integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q== } + '@radix-ui/react-focus-guards@1.1.4': + resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-focus-scope@1.1.9": - resolution: - { integrity: sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ== } + '@radix-ui/react-focus-scope@1.1.9': + resolution: {integrity: sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-form@0.1.9": - resolution: - { integrity: sha512-eTPyThIKDacJ3mJDvYwf/PSmsEYlOyA2Qcb+aGyWwYv+P5w57VPUkMVA2XJ9z0Du2KBY1HoHQzhPV9iYL/r4hg== } + '@radix-ui/react-form@0.1.9': + resolution: {integrity: sha512-eTPyThIKDacJ3mJDvYwf/PSmsEYlOyA2Qcb+aGyWwYv+P5w57VPUkMVA2XJ9z0Du2KBY1HoHQzhPV9iYL/r4hg==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-hover-card@1.1.16": - resolution: - { integrity: sha512-hAileDBtd6CX7nlZOarOnISQ6PP4q0e16BX51ulzdZ+7IzjL0sDTVpFdmSYrIjw6zVNsfQBao5gG6AWr3qwfvA== } + '@radix-ui/react-hover-card@1.1.16': + resolution: {integrity: sha512-hAileDBtd6CX7nlZOarOnISQ6PP4q0e16BX51ulzdZ+7IzjL0sDTVpFdmSYrIjw6zVNsfQBao5gG6AWr3qwfvA==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-id@1.1.2": - resolution: - { integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA== } + '@radix-ui/react-id@1.1.2': + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-label@2.1.9": - resolution: - { integrity: sha512-rDoTeMbCwRVcnmo7NGT9IlPo1yXmEI+xc1URP3oeewwZEV4mdTp1dYUhYbQdo4D1q2SjKVvv4N1gNY77QAQtjA== } + '@radix-ui/react-label@2.1.9': + resolution: {integrity: sha512-rDoTeMbCwRVcnmo7NGT9IlPo1yXmEI+xc1URP3oeewwZEV4mdTp1dYUhYbQdo4D1q2SjKVvv4N1gNY77QAQtjA==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-menu@2.1.17": - resolution: - { integrity: sha512-fmbNnFyf+JYCN0DhhWnEdUTDnZD1mXaPQWivdsPIb8oOSbARfD3LIQJbLCG8a8QLCwoMxiJ7GVPIFcC8Dw8v2Q== } + '@radix-ui/react-menu@2.1.17': + resolution: {integrity: sha512-fmbNnFyf+JYCN0DhhWnEdUTDnZD1mXaPQWivdsPIb8oOSbARfD3LIQJbLCG8a8QLCwoMxiJ7GVPIFcC8Dw8v2Q==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-menubar@1.1.17": - resolution: - { integrity: sha512-AKtZ4O782yO7qwIyq73WpulYt1IHhQ0htDb6wNcxzxnSDCcSWMVBiU9ycpcA90XzQO4IVIxIErtak6Kg/Vt0rQ== } + '@radix-ui/react-menubar@1.1.17': + resolution: {integrity: sha512-AKtZ4O782yO7qwIyq73WpulYt1IHhQ0htDb6wNcxzxnSDCcSWMVBiU9ycpcA90XzQO4IVIxIErtak6Kg/Vt0rQ==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-navigation-menu@1.2.15": - resolution: - { integrity: sha512-/fS8hKCcRt4DwCGa5QIB3juRXmfYSOk4a2AEe/BDIyy7Hm+eje2Y13oUx5zejl+wFt1owrM7E8NWlbaEl5EGpg== } + '@radix-ui/react-navigation-menu@1.2.15': + resolution: {integrity: sha512-/fS8hKCcRt4DwCGa5QIB3juRXmfYSOk4a2AEe/BDIyy7Hm+eje2Y13oUx5zejl+wFt1owrM7E8NWlbaEl5EGpg==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-one-time-password-field@0.1.9": - resolution: - { integrity: sha512-fvCzA9hm7yN5xxTPJIi4VhSmH5gv+76ILsxguBK3cm3icD5BR4vW7POQmu8Zio0yh91uuouG/Kang40IbMkaSQ== } + '@radix-ui/react-one-time-password-field@0.1.9': + resolution: {integrity: sha512-fvCzA9hm7yN5xxTPJIi4VhSmH5gv+76ILsxguBK3cm3icD5BR4vW7POQmu8Zio0yh91uuouG/Kang40IbMkaSQ==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-password-toggle-field@0.1.4": - resolution: - { integrity: sha512-qoDSkObZ9faJlsjlwyBH6ia7kq9vaJ2QwWTowT3nQpzPvUTAKesmWuGJYpd91HIoJqS+5ZPXy5uFPp+HlwdaAg== } + '@radix-ui/react-password-toggle-field@0.1.4': + resolution: {integrity: sha512-qoDSkObZ9faJlsjlwyBH6ia7kq9vaJ2QwWTowT3nQpzPvUTAKesmWuGJYpd91HIoJqS+5ZPXy5uFPp+HlwdaAg==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-popover@1.1.16": - resolution: - { integrity: sha512-8brVpAU5Uq7Bh0c8EFc4ZTf2JJTYn0o+1L+CUJB3UYIOkTjKGMgoHvduylrahdmNlr3DfH0rFq2DrbNZXgaspw== } + '@radix-ui/react-popover@1.1.16': + resolution: {integrity: sha512-8brVpAU5Uq7Bh0c8EFc4ZTf2JJTYn0o+1L+CUJB3UYIOkTjKGMgoHvduylrahdmNlr3DfH0rFq2DrbNZXgaspw==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-popper@1.3.0": - resolution: - { integrity: sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ== } + '@radix-ui/react-popper@1.3.0': + resolution: {integrity: sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-portal@1.1.11": - resolution: - { integrity: sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw== } + '@radix-ui/react-portal@1.1.11': + resolution: {integrity: sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-presence@1.1.6": - resolution: - { integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ== } + '@radix-ui/react-presence@1.1.6': + resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-primitive@2.1.5": - resolution: - { integrity: sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA== } + '@radix-ui/react-primitive@2.1.5': + resolution: {integrity: sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-progress@1.1.9": - resolution: - { integrity: sha512-+EOkvg1Zn1vI1+fRDfRSAiJ7BWfcDAo5ASMmbqrcLZ4s4USk2FGkoHgeb2X+CkUgo2zJMiyObwf1k44CrRWsyw== } + '@radix-ui/react-progress@1.1.9': + resolution: {integrity: sha512-+EOkvg1Zn1vI1+fRDfRSAiJ7BWfcDAo5ASMmbqrcLZ4s4USk2FGkoHgeb2X+CkUgo2zJMiyObwf1k44CrRWsyw==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-radio-group@1.4.0": - resolution: - { integrity: sha512-eHdV5bLx9sH+tBnbDjkIBdvQEH/c6MEtQYhTbxkaDK9qsIFFLtmJYEQFVdwhnruWotLfQmIuWEL/J+L3utE8rQ== } + '@radix-ui/react-radio-group@1.4.0': + resolution: {integrity: sha512-eHdV5bLx9sH+tBnbDjkIBdvQEH/c6MEtQYhTbxkaDK9qsIFFLtmJYEQFVdwhnruWotLfQmIuWEL/J+L3utE8rQ==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-roving-focus@1.1.12": - resolution: - { integrity: sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg== } + '@radix-ui/react-roving-focus@1.1.12': + resolution: {integrity: sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-scroll-area@1.2.11": - resolution: - { integrity: sha512-DS39ziOgea75U/TrXKU2/oKp0be2jrDHnzFLvahg/0iNAT1Zq16e4Uw0WXwyXvsK+mG3BRyMb7A3NRZMDuEXtQ== } + '@radix-ui/react-scroll-area@1.2.11': + resolution: {integrity: sha512-DS39ziOgea75U/TrXKU2/oKp0be2jrDHnzFLvahg/0iNAT1Zq16e4Uw0WXwyXvsK+mG3BRyMb7A3NRZMDuEXtQ==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-select@2.3.0": - resolution: - { integrity: sha512-mENc7WpJvJcW8hlMpzfFcHcEhTvYS5JMBmi9HVC1Q00uhBwML086MHYUV8QQdQv6lcu0Wg8dzd1RB8AFADcG/g== } + '@radix-ui/react-select@2.3.0': + resolution: {integrity: sha512-mENc7WpJvJcW8hlMpzfFcHcEhTvYS5JMBmi9HVC1Q00uhBwML086MHYUV8QQdQv6lcu0Wg8dzd1RB8AFADcG/g==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-separator@1.1.9": - resolution: - { integrity: sha512-gvgW+JV/Mbjj6darztTetnmElpQEzZrXpJvfj+dOxNAxiyHEAyUvEjjl4zxblvmjmKmi3jfPoy7ZdxzCuUBJSA== } + '@radix-ui/react-separator@1.1.9': + resolution: {integrity: sha512-gvgW+JV/Mbjj6darztTetnmElpQEzZrXpJvfj+dOxNAxiyHEAyUvEjjl4zxblvmjmKmi3jfPoy7ZdxzCuUBJSA==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-slider@1.4.0": - resolution: - { integrity: sha512-RHcPlLOThRJM51DSIC33ZnpDEBYhyEFroVWkd2P54PGGjkmAt14RboYUU9E1MFst666zFHM0tGtWvMjSOtU1pw== } + '@radix-ui/react-slider@1.4.0': + resolution: {integrity: sha512-RHcPlLOThRJM51DSIC33ZnpDEBYhyEFroVWkd2P54PGGjkmAt14RboYUU9E1MFst666zFHM0tGtWvMjSOtU1pw==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-slot@1.2.5": - resolution: - { integrity: sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg== } + '@radix-ui/react-slot@1.2.5': + resolution: {integrity: sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-switch@1.3.0": - resolution: - { integrity: sha512-GP1EZwhoZO/GGnhM1P5/2Vpm8iN8EnngyU0oezn2l78kN8tj25pyrvjIaT7azBhK615KSt+P2w39y57YV5jVkA== } + '@radix-ui/react-switch@1.3.0': + resolution: {integrity: sha512-GP1EZwhoZO/GGnhM1P5/2Vpm8iN8EnngyU0oezn2l78kN8tj25pyrvjIaT7azBhK615KSt+P2w39y57YV5jVkA==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-tabs@1.1.14": - resolution: - { integrity: sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA== } + '@radix-ui/react-tabs@1.1.14': + resolution: {integrity: sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-toast@1.2.16": - resolution: - { integrity: sha512-WUymDDiN2DpoGudRN1aW4wF5O3BNQjZZO/5nngPoNiEVqjyOzirvZZNO0R6dC1ifucSINVaSv8JX1aq47VGgiA== } + '@radix-ui/react-toast@1.2.16': + resolution: {integrity: sha512-WUymDDiN2DpoGudRN1aW4wF5O3BNQjZZO/5nngPoNiEVqjyOzirvZZNO0R6dC1ifucSINVaSv8JX1aq47VGgiA==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-toggle-group@1.1.12": - resolution: - { integrity: sha512-TEgECgJaWGAHJJZGzNNEYTNBdIXqX7LchANycpyP7DkfjmuiSN7ISt1k/ZRGVJgVJonsgP4vwaiKMn5utrcwWQ== } + '@radix-ui/react-toggle-group@1.1.12': + resolution: {integrity: sha512-TEgECgJaWGAHJJZGzNNEYTNBdIXqX7LchANycpyP7DkfjmuiSN7ISt1k/ZRGVJgVJonsgP4vwaiKMn5utrcwWQ==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-toggle@1.1.11": - resolution: - { integrity: sha512-FikrKJemoBGZQ6uRID0HJqSPBP6D7OppdD2OhLl0ZYLlAyPXI7MezoYGmumwNkrAoRm35xXkb4C8JPfJZZzcaw== } + '@radix-ui/react-toggle@1.1.11': + resolution: {integrity: sha512-FikrKJemoBGZQ6uRID0HJqSPBP6D7OppdD2OhLl0ZYLlAyPXI7MezoYGmumwNkrAoRm35xXkb4C8JPfJZZzcaw==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-toolbar@1.1.12": - resolution: - { integrity: sha512-4wHtJVdIgqMmEwUvxA0BYg/2JMRbt0L3+8UD8Ml/nhKkfXtiZcM8u/S15gQ5xj9YEd/0qlrm5bE805LsjQ+J8A== } + '@radix-ui/react-toolbar@1.1.12': + resolution: {integrity: sha512-4wHtJVdIgqMmEwUvxA0BYg/2JMRbt0L3+8UD8Ml/nhKkfXtiZcM8u/S15gQ5xj9YEd/0qlrm5bE805LsjQ+J8A==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-tooltip@1.2.9": - resolution: - { integrity: sha512-u6F9MmTtBSLkiXNVDrtB/yPCZarM9smNswC24YYLV/M+bth6J3Gs3vlJezEoFwKZvPvxhCpUYdUnOsNG/0XOlA== } + '@radix-ui/react-tooltip@1.2.9': + resolution: {integrity: sha512-u6F9MmTtBSLkiXNVDrtB/yPCZarM9smNswC24YYLV/M+bth6J3Gs3vlJezEoFwKZvPvxhCpUYdUnOsNG/0XOlA==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/react-use-callback-ref@1.1.2": - resolution: - { integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw== } + '@radix-ui/react-use-callback-ref@1.1.2': + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-use-controllable-state@1.2.3": - resolution: - { integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA== } + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-use-effect-event@0.0.3": - resolution: - { integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA== } + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-use-escape-keydown@1.1.2": - resolution: - { integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw== } + '@radix-ui/react-use-escape-keydown@1.1.2': + resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-use-is-hydrated@0.1.1": - resolution: - { integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A== } + '@radix-ui/react-use-is-hydrated@0.1.1': + resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-use-layout-effect@1.1.2": - resolution: - { integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA== } + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-use-previous@1.1.2": - resolution: - { integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw== } + '@radix-ui/react-use-previous@1.1.2': + resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-use-rect@1.1.2": - resolution: - { integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw== } + '@radix-ui/react-use-rect@1.1.2': + resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-use-size@1.1.2": - resolution: - { integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w== } + '@radix-ui/react-use-size@1.1.2': + resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@radix-ui/react-visually-hidden@1.2.5": - resolution: - { integrity: sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg== } + '@radix-ui/react-visually-hidden@1.2.5': + resolution: {integrity: sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@radix-ui/rect@1.1.2": - resolution: - { integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA== } + '@radix-ui/rect@1.1.2': + resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} - "@redocly/ajv@8.11.2": - resolution: - { integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg== } + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} - "@redocly/config@0.22.0": - resolution: - { integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ== } + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} - "@redocly/openapi-core@1.34.15": - resolution: - { integrity: sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q== } - engines: { node: ">=18.17.0", npm: ">=9.5.0" } + '@redocly/openapi-core@1.34.15': + resolution: {integrity: sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} - "@rolldown/binding-android-arm64@1.0.3": - resolution: - { integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - "@rolldown/binding-darwin-arm64@1.0.3": - resolution: - { integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - "@rolldown/binding-darwin-x64@1.0.3": - resolution: - { integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - "@rolldown/binding-freebsd-x64@1.0.3": - resolution: - { integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - "@rolldown/binding-linux-arm-gnueabihf@1.0.3": - resolution: - { integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - "@rolldown/binding-linux-arm64-gnu@1.0.3": - resolution: - { integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] - "@rolldown/binding-linux-arm64-musl@1.0.3": - resolution: - { integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] - "@rolldown/binding-linux-ppc64-gnu@1.0.3": - resolution: - { integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] - "@rolldown/binding-linux-s390x-gnu@1.0.3": - resolution: - { integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] - "@rolldown/binding-linux-x64-gnu@1.0.3": - resolution: - { integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] - "@rolldown/binding-linux-x64-musl@1.0.3": - resolution: - { integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] - "@rolldown/binding-openharmony-arm64@1.0.3": - resolution: - { integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - "@rolldown/binding-wasm32-wasi@1.0.3": - resolution: - { integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - "@rolldown/binding-win32-arm64-msvc@1.0.3": - resolution: - { integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - "@rolldown/binding-win32-x64-msvc@1.0.3": - resolution: - { integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - "@rolldown/pluginutils@1.0.1": - resolution: - { integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== } + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - "@sindresorhus/is@4.6.0": - resolution: - { integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw== } - engines: { node: ">=10" } + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} - "@standard-schema/spec@1.1.0": - resolution: - { integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== } + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - "@szmarczak/http-timer@4.0.6": - resolution: - { integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w== } - engines: { node: ">=10" } + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} - "@tailwindcss/node@4.3.0": - resolution: - { integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g== } + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} - "@tailwindcss/oxide-android-arm64@4.3.0": - resolution: - { integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng== } - engines: { node: ">= 20" } + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} cpu: [arm64] os: [android] - "@tailwindcss/oxide-darwin-arm64@4.3.0": - resolution: - { integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ== } - engines: { node: ">= 20" } + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - "@tailwindcss/oxide-darwin-x64@4.3.0": - resolution: - { integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA== } - engines: { node: ">= 20" } + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} cpu: [x64] os: [darwin] - "@tailwindcss/oxide-freebsd-x64@4.3.0": - resolution: - { integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ== } - engines: { node: ">= 20" } + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - "@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0": - resolution: - { integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA== } - engines: { node: ">= 20" } + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} cpu: [arm] os: [linux] - "@tailwindcss/oxide-linux-arm64-gnu@4.3.0": - resolution: - { integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg== } - engines: { node: ">= 20" } + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] - "@tailwindcss/oxide-linux-arm64-musl@4.3.0": - resolution: - { integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ== } - engines: { node: ">= 20" } + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] - "@tailwindcss/oxide-linux-x64-gnu@4.3.0": - resolution: - { integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ== } - engines: { node: ">= 20" } + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] - "@tailwindcss/oxide-linux-x64-musl@4.3.0": - resolution: - { integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg== } - engines: { node: ">= 20" } + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] - "@tailwindcss/oxide-wasm32-wasi@4.3.0": - resolution: - { integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA== } - engines: { node: ">=14.0.0" } + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: - - "@napi-rs/wasm-runtime" - - "@emnapi/core" - - "@emnapi/runtime" - - "@tybys/wasm-util" - - "@emnapi/wasi-threads" + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' - tslib - "@tailwindcss/oxide-win32-arm64-msvc@4.3.0": - resolution: - { integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ== } - engines: { node: ">= 20" } + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [win32] - "@tailwindcss/oxide-win32-x64-msvc@4.3.0": - resolution: - { integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA== } - engines: { node: ">= 20" } + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} cpu: [x64] os: [win32] - "@tailwindcss/oxide@4.3.0": - resolution: - { integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg== } - engines: { node: ">= 20" } + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} - "@tailwindcss/vite@4.3.0": - resolution: - { integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw== } + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - "@tanstack/history@1.162.0": - resolution: - { integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA== } - engines: { node: ">=20.19" } + '@tanstack/history@1.162.0': + resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} + engines: {node: '>=20.19'} - "@tanstack/query-core@5.101.0": - resolution: - { integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow== } + '@tanstack/query-core@5.101.0': + resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} - "@tanstack/react-query@5.101.0": - resolution: - { integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg== } + '@tanstack/react-query@5.101.0': + resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} peerDependencies: react: ^18 || ^19 - "@tanstack/react-router@1.170.15": - resolution: - { integrity: sha512-GawYz7HEjj8rTUUDoT/SemDEVm63pZUO+2mOcXHY9Jl3EwMS5gFBnPu/2UvcrwRm1jN1k79fokc0d4aFmrLatg== } - engines: { node: ">=20.19" } + '@tanstack/react-router@1.170.15': + resolution: {integrity: sha512-GawYz7HEjj8rTUUDoT/SemDEVm63pZUO+2mOcXHY9Jl3EwMS5gFBnPu/2UvcrwRm1jN1k79fokc0d4aFmrLatg==} + engines: {node: '>=20.19'} peerDependencies: - react: ">=18.0.0 || >=19.0.0" - react-dom: ">=18.0.0 || >=19.0.0" + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' - "@tanstack/react-store@0.9.3": - resolution: - { integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg== } + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - "@tanstack/router-core@1.171.13": - resolution: - { integrity: sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA== } - engines: { node: ">=20.19" } + '@tanstack/router-core@1.171.13': + resolution: {integrity: sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==} + engines: {node: '>=20.19'} - "@tanstack/router-generator@1.167.17": - resolution: - { integrity: sha512-xtB9tB2Ws0tWR6Pi7nc3Qk9IYgoh1mQCKWjHqIl9tf6BNUpKoqniJoPAQ4+LGrK8FeZYU0o0p/qlZEyj9FAulA== } - engines: { node: ">=20.19" } + '@tanstack/router-generator@1.167.17': + resolution: {integrity: sha512-xtB9tB2Ws0tWR6Pi7nc3Qk9IYgoh1mQCKWjHqIl9tf6BNUpKoqniJoPAQ4+LGrK8FeZYU0o0p/qlZEyj9FAulA==} + engines: {node: '>=20.19'} - "@tanstack/router-plugin@1.168.18": - resolution: - { integrity: sha512-MofS28/axfnfnhOD2RSgJEaU882aX5RsAzhGz5Vc4XhAmvCjy919u9JrNs4QsTWFbTD1P7IJ8WFlFVsrg0pStg== } - engines: { node: ">=20.19" } + '@tanstack/router-plugin@1.168.18': + resolution: {integrity: sha512-MofS28/axfnfnhOD2RSgJEaU882aX5RsAzhGz5Vc4XhAmvCjy919u9JrNs4QsTWFbTD1P7IJ8WFlFVsrg0pStg==} + engines: {node: '>=20.19'} peerDependencies: - "@rsbuild/core": ">=1.0.2 || ^2.0.0" - "@tanstack/react-router": ^1.170.15 - vite: ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0" + '@rsbuild/core': '>=1.0.2 || ^2.0.0' + '@tanstack/react-router': ^1.170.15 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0' vite-plugin-solid: ^2.11.10 || ^3.0.0-0 - webpack: ">=5.92.0" + webpack: '>=5.92.0' peerDependenciesMeta: - "@rsbuild/core": + '@rsbuild/core': optional: true - "@tanstack/react-router": + '@tanstack/react-router': optional: true vite: optional: true @@ -1814,157 +1638,136 @@ packages: webpack: optional: true - "@tanstack/router-utils@1.162.2": - resolution: - { integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ== } - engines: { node: ">=20.19" } + '@tanstack/router-utils@1.162.2': + resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==} + engines: {node: '>=20.19'} - "@tanstack/store@0.9.3": - resolution: - { integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw== } + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} - "@tanstack/virtual-file-routes@1.162.0": - resolution: - { integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA== } - engines: { node: ">=20.19" } + '@tanstack/virtual-file-routes@1.162.0': + resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} + engines: {node: '>=20.19'} - "@testing-library/dom@10.4.1": - resolution: - { integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== } - engines: { node: ">=18" } + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} - "@testing-library/jest-dom@6.9.1": - resolution: - { integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA== } - engines: { node: ">=14", npm: ">=6", yarn: ">=1" } + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - "@testing-library/react@16.3.2": - resolution: - { integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g== } - engines: { node: ">=18" } + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} peerDependencies: - "@testing-library/dom": ^10.0.0 - "@types/react": ^18.0.0 || ^19.0.0 - "@types/react-dom": ^18.0.0 || ^19.0.0 + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true - "@testing-library/user-event@14.6.1": - resolution: - { integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== } - engines: { node: ">=12", npm: ">=6" } + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} peerDependencies: - "@testing-library/dom": ">=7.21.4" + '@testing-library/dom': '>=7.21.4' - "@tootallnate/once@2.0.1": - resolution: - { integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ== } - engines: { node: ">= 10" } + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} + engines: {node: '>= 10'} - "@tybys/wasm-util@0.10.2": - resolution: - { integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg== } + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - "@types/aria-query@5.0.4": - resolution: - { integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== } + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - "@types/cacheable-request@6.0.3": - resolution: - { integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw== } + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} - "@types/chai@5.2.3": - resolution: - { integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== } + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - "@types/deep-eql@4.0.2": - resolution: - { integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== } + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} - "@types/estree@1.0.9": - resolution: - { integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== } + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - "@types/fs-extra@9.0.13": - resolution: - { integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA== } + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - "@types/http-cache-semantics@4.2.0": - resolution: - { integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q== } + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} - "@types/json-schema@7.0.15": - resolution: - { integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== } + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} - "@types/keyv@3.1.4": - resolution: - { integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg== } + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - "@types/mute-stream@0.0.4": - resolution: - { integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow== } + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} - "@types/node@20.19.42": - resolution: - { integrity: sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg== } + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - "@types/node@22.19.20": - resolution: - { integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw== } + '@types/mute-stream@0.0.4': + resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} - "@types/node@25.9.2": - resolution: - { integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw== } + '@types/node@20.19.42': + resolution: {integrity: sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==} - "@types/react-dom@19.2.3": - resolution: - { integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== } + '@types/node@22.19.20': + resolution: {integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==} + + '@types/node@25.9.2': + resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: - "@types/react": ^19.2.0 + '@types/react': ^19.2.0 - "@types/react@19.2.17": - resolution: - { integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw== } + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - "@types/responselike@1.0.3": - resolution: - { integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw== } + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - "@types/wrap-ansi@3.0.0": - resolution: - { integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g== } + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - "@types/yauzl@2.10.3": - resolution: - { integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q== } + '@types/wrap-ansi@3.0.0': + resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} - "@vitejs/plugin-react@6.0.2": - resolution: - { integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - "@rolldown/plugin-babel": ^0.1.7 || ^0.2.0 + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 babel-plugin-react-compiler: ^1.0.0 vite: ^8.0.0 peerDependenciesMeta: - "@rolldown/plugin-babel": + '@rolldown/plugin-babel': optional: true babel-plugin-react-compiler: optional: true - "@vitest/expect@4.1.8": - resolution: - { integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ== } + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} - "@vitest/mocker@4.1.8": - resolution: - { integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw== } + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1974,179 +1777,148 @@ packages: vite: optional: true - "@vitest/pretty-format@4.1.8": - resolution: - { integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA== } + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} - "@vitest/runner@4.1.8": - resolution: - { integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg== } + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} - "@vitest/snapshot@4.1.8": - resolution: - { integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ== } + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} - "@vitest/spy@4.1.8": - resolution: - { integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA== } + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} - "@vitest/utils@4.1.8": - resolution: - { integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg== } + '@vscode/sudo-prompt@9.3.2': + resolution: {integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==} - "@vscode/sudo-prompt@9.3.2": - resolution: - { integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw== } + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} - "@webassemblyjs/ast@1.14.1": - resolution: - { integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ== } + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} - "@webassemblyjs/floating-point-hex-parser@1.13.2": - resolution: - { integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA== } + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} - "@webassemblyjs/helper-api-error@1.13.2": - resolution: - { integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ== } + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} - "@webassemblyjs/helper-buffer@1.14.1": - resolution: - { integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA== } + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} - "@webassemblyjs/helper-numbers@1.13.2": - resolution: - { integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA== } + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} - "@webassemblyjs/helper-wasm-bytecode@1.13.2": - resolution: - { integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA== } + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} - "@webassemblyjs/helper-wasm-section@1.14.1": - resolution: - { integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw== } + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} - "@webassemblyjs/ieee754@1.13.2": - resolution: - { integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw== } + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} - "@webassemblyjs/leb128@1.13.2": - resolution: - { integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw== } + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} - "@webassemblyjs/utf8@1.13.2": - resolution: - { integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ== } + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} - "@webassemblyjs/wasm-edit@1.14.1": - resolution: - { integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ== } + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} - "@webassemblyjs/wasm-gen@1.14.1": - resolution: - { integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg== } + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} - "@webassemblyjs/wasm-opt@1.14.1": - resolution: - { integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw== } + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} - "@webassemblyjs/wasm-parser@1.14.1": - resolution: - { integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ== } + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} - "@webassemblyjs/wast-printer@1.14.1": - resolution: - { integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw== } + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} - "@xmldom/xmldom@0.9.10": - resolution: - { integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw== } - engines: { node: ">=14.6" } + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} - "@xterm/addon-canvas@0.7.0": - resolution: - { integrity: sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw== } + '@xterm/addon-canvas@0.7.0': + resolution: {integrity: sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==} peerDependencies: - "@xterm/xterm": ^5.0.0 + '@xterm/xterm': ^5.0.0 - "@xterm/addon-fit@0.10.0": - resolution: - { integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ== } + '@xterm/addon-fit@0.10.0': + resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} peerDependencies: - "@xterm/xterm": ^5.0.0 + '@xterm/xterm': ^5.0.0 - "@xterm/addon-search@0.15.0": - resolution: - { integrity: sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg== } + '@xterm/addon-search@0.15.0': + resolution: {integrity: sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==} peerDependencies: - "@xterm/xterm": ^5.0.0 + '@xterm/xterm': ^5.0.0 - "@xterm/addon-unicode11@0.9.0": - resolution: - { integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw== } + '@xterm/addon-unicode11@0.9.0': + resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==} - "@xterm/addon-web-links@0.11.0": - resolution: - { integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q== } + '@xterm/addon-web-links@0.11.0': + resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==} peerDependencies: - "@xterm/xterm": ^5.0.0 + '@xterm/xterm': ^5.0.0 - "@xterm/addon-webgl@0.19.0": - resolution: - { integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A== } + '@xterm/addon-webgl@0.19.0': + resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==} - "@xterm/xterm@5.5.0": - resolution: - { integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A== } + '@xterm/xterm@5.5.0': + resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} - "@xtuc/ieee754@1.2.0": - resolution: - { integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== } + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} - "@xtuc/long@4.2.2": - resolution: - { integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== } + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} abbrev@1.1.1: - resolution: - { integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== } + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} acorn-import-phases@1.0.4: - resolution: - { integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ== } - engines: { node: ">=10.13.0" } + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} peerDependencies: acorn: ^8.14.0 acorn@8.16.0: - resolution: - { integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== } - engines: { node: ">=0.4.0" } + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} hasBin: true agent-base@6.0.2: - resolution: - { integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== } - engines: { node: ">= 6.0.0" } + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} agent-base@7.1.4: - resolution: - { integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== } - engines: { node: ">= 14" } + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} agentkeepalive@4.6.0: - resolution: - { integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== } - engines: { node: ">= 8.0.0" } + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} aggregate-error@3.1.0: - resolution: - { integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== } - engines: { node: ">=8" } + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} ajv-formats@2.1.1: - resolution: - { integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== } + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: ajv: ^8.0.0 peerDependenciesMeta: @@ -2154,1045 +1926,998 @@ packages: optional: true ajv-keywords@5.1.0: - resolution: - { integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw== } + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: ajv: ^8.8.2 ajv@8.20.0: - resolution: - { integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== } + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} ansi-colors@4.1.3: - resolution: - { integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== } - engines: { node: ">=6" } + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} ansi-escapes@4.3.2: - resolution: - { integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== } - engines: { node: ">=8" } + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} ansi-escapes@5.0.0: - resolution: - { integrity: sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA== } - engines: { node: ">=12" } + resolution: {integrity: sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==} + engines: {node: '>=12'} ansi-regex@5.0.1: - resolution: - { integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== } - engines: { node: ">=8" } + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} ansi-regex@6.2.2: - resolution: - { integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== } - engines: { node: ">=12" } + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} ansi-styles@4.3.0: - resolution: - { integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== } - engines: { node: ">=8" } + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} ansi-styles@5.2.0: - resolution: - { integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== } - engines: { node: ">=10" } + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} ansi-styles@6.2.3: - resolution: - { integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== } - engines: { node: ">=12" } + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} ansis@4.3.1: - resolution: - { integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA== } - engines: { node: ">=14" } + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + + app-builder-lib@26.15.3: + resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==} + engines: {node: '>=14.0.0'} + peerDependencies: + dmg-builder: 26.15.3 + electron-builder-squirrel-windows: 26.15.3 argparse@2.0.1: - resolution: - { integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== } + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} aria-hidden@1.2.6: - resolution: - { integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA== } - engines: { node: ">=10" } + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} aria-query@5.3.0: - resolution: - { integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== } + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} aria-query@5.3.2: - resolution: - { integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} assertion-error@2.0.1: - resolution: - { integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== } - engines: { node: ">=12" } + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} at-least-node@1.0.0: - resolution: - { integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== } - engines: { node: ">= 4.0.0" } + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} author-regex@1.0.0: - resolution: - { integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g== } - engines: { node: ">=0.8" } + resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==} + engines: {node: '>=0.8'} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} babel-dead-code-elimination@1.0.12: - resolution: - { integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig== } + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} balanced-match@1.0.2: - resolution: - { integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== } + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} base64-js@1.5.1: - resolution: - { integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== } + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} baseline-browser-mapping@2.10.35: - resolution: - { integrity: sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg== } - engines: { node: ">=6.0.0" } + resolution: {integrity: sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==} + engines: {node: '>=6.0.0'} hasBin: true before-after-hook@2.2.3: - resolution: - { integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ== } + resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} bidi-js@1.0.3: - resolution: - { integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw== } + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} bl@4.1.0: - resolution: - { integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== } + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} bluebird@3.7.2: - resolution: - { integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== } + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} boolean@3.2.0: - resolution: - { integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw== } + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. bottleneck@2.19.5: - resolution: - { integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw== } + resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} brace-expansion@1.1.15: - resolution: - { integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg== } + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} brace-expansion@2.1.1: - resolution: - { integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA== } + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: - resolution: - { integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== } - engines: { node: ">=8" } + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} browserslist@4.28.2: - resolution: - { integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg== } - engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true buffer-crc32@0.2.13: - resolution: - { integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== } + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} buffer-from@1.1.2: - resolution: - { integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== } + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} buffer@5.7.1: - resolution: - { integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== } + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} + engines: {node: '>=12.0.0'} + + builder-util@26.15.3: + resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==} + engines: {node: '>=14.0.0'} + + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} cacache@16.1.3: - resolution: - { integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ== } - engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} cacheable-lookup@5.0.4: - resolution: - { integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA== } - engines: { node: ">=10.6.0" } + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} cacheable-request@7.0.4: - resolution: - { integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg== } - engines: { node: ">=8" } + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} caniuse-lite@1.0.30001797: - resolution: - { integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w== } + resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} chai@6.2.2: - resolution: - { integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== } - engines: { node: ">=18" } + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} chalk@4.1.2: - resolution: - { integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== } - engines: { node: ">=10" } + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} change-case@5.4.4: - resolution: - { integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w== } + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} chardet@0.7.0: - resolution: - { integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== } + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} chokidar@5.0.0: - resolution: - { integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw== } - engines: { node: ">= 20.19.0" } + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} chownr@2.0.0: - resolution: - { integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== } - engines: { node: ">=10" } + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} chrome-trace-event@1.0.4: - resolution: - { integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ== } - engines: { node: ">=6.0" } + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + chromium-pickle-js@0.2.0: + resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} + + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} class-variance-authority@0.7.1: - resolution: - { integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg== } + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} clean-stack@2.2.0: - resolution: - { integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== } - engines: { node: ">=6" } + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} cli-cursor@3.1.0: - resolution: - { integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== } - engines: { node: ">=8" } + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} cli-cursor@4.0.0: - resolution: - { integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg== } - engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} cli-spinners@2.9.2: - resolution: - { integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== } - engines: { node: ">=6" } + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} cli-truncate@3.1.0: - resolution: - { integrity: sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA== } - engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + resolution: {integrity: sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} cli-width@4.1.0: - resolution: - { integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ== } - engines: { node: ">= 12" } + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} cliui@7.0.4: - resolution: - { integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== } + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} cliui@8.0.1: - resolution: - { integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== } - engines: { node: ">=12" } + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} clone-response@1.0.3: - resolution: - { integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA== } + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} clone@1.0.4: - resolution: - { integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== } - engines: { node: ">=0.8" } + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} clsx@2.1.1: - resolution: - { integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== } - engines: { node: ">=6" } + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} color-convert@2.0.1: - resolution: - { integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== } - engines: { node: ">=7.0.0" } + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} color-name@1.1.4: - resolution: - { integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== } + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} colorette@1.4.0: - resolution: - { integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g== } + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} colorette@2.0.20: - resolution: - { integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== } + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} commander@11.1.0: - resolution: - { integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ== } - engines: { node: ">=16" } + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} commander@2.20.3: - resolution: - { integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== } + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} commander@5.1.0: - resolution: - { integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== } - engines: { node: ">= 6" } + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} commander@9.5.0: - resolution: - { integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ== } - engines: { node: ^12.20.0 || >=14 } + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} compare-version@0.1.2: - resolution: - { integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} + engines: {node: '>=0.10.0'} concat-map@0.0.1: - resolution: - { integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== } + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} convert-source-map@2.0.0: - resolution: - { integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== } + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-es@3.1.1: - resolution: - { integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg== } + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} cross-dirname@0.1.0: - resolution: - { integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q== } + resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} cross-spawn@6.0.6: - resolution: - { integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw== } - engines: { node: ">=4.8" } + resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} + engines: {node: '>=4.8'} cross-spawn@7.0.6: - resolution: - { integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== } - engines: { node: ">= 8" } + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} cross-zip@4.0.1: - resolution: - { integrity: sha512-n63i0lZ0rvQ6FXiGQ+/JFCKAUyPFhLQYJIqKaa+tSJtfKeULF/IDNDAbdnSIxgS4NTuw2b0+lj8LzfITuq+ZxQ== } - engines: { node: ">=12.10" } + resolution: {integrity: sha512-n63i0lZ0rvQ6FXiGQ+/JFCKAUyPFhLQYJIqKaa+tSJtfKeULF/IDNDAbdnSIxgS4NTuw2b0+lj8LzfITuq+ZxQ==} + engines: {node: '>=12.10'} css-tree@3.2.1: - resolution: - { integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA== } - engines: { node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0 } + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} css.escape@1.5.1: - resolution: - { integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== } + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} csstype@3.2.3: - resolution: - { integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== } + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} data-urls@7.0.0: - resolution: - { integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA== } - engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} debug@2.6.9: - resolution: - { integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== } + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: - supports-color: "*" + supports-color: '*' peerDependenciesMeta: supports-color: optional: true debug@4.4.3: - resolution: - { integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== } - engines: { node: ">=6.0" } + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} peerDependencies: - supports-color: "*" + supports-color: '*' peerDependenciesMeta: supports-color: optional: true decimal.js@10.6.0: - resolution: - { integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== } + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} decompress-response@6.0.0: - resolution: - { integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== } - engines: { node: ">=10" } + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} defaults@1.0.4: - resolution: - { integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A== } + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} defer-to-connect@2.0.1: - resolution: - { integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== } - engines: { node: ">=10" } + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} define-data-property@1.1.4: - resolution: - { integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} define-properties@1.2.1: - resolution: - { integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} deprecation@2.3.1: - resolution: - { integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== } + resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} dequal@2.0.3: - resolution: - { integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== } - engines: { node: ">=6" } + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} detect-libc@2.1.2: - resolution: - { integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== } - engines: { node: ">=8" } + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} detect-node-es@1.1.0: - resolution: - { integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== } + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} detect-node@2.1.0: - resolution: - { integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== } + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} diff@8.0.4: - resolution: - { integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw== } - engines: { node: ">=0.3.1" } + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} dir-compare@4.2.0: - resolution: - { integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ== } + resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + + dmg-builder@26.15.3: + resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==} dom-accessibility-api@0.5.16: - resolution: - { integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== } + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dom-accessibility-api@0.6.3: - resolution: - { integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== } + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} eastasianwidth@0.2.0: - resolution: - { integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== } + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-builder-squirrel-windows@26.15.3: + resolution: {integrity: sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==} electron-installer-common@0.10.4: - resolution: - { integrity: sha512-8gMNPXfAqUE5CfXg8RL0vXpLE9HAaPkgLXVoHE3BMUzogMWenf4LmwQ27BdCUrEhkjrKl+igs2IHJibclR3z3Q== } - engines: { node: ">= 10.0.0" } + resolution: {integrity: sha512-8gMNPXfAqUE5CfXg8RL0vXpLE9HAaPkgLXVoHE3BMUzogMWenf4LmwQ27BdCUrEhkjrKl+igs2IHJibclR3z3Q==} + engines: {node: '>= 10.0.0'} electron-installer-debian@3.2.0: - resolution: - { integrity: sha512-58ZrlJ1HQY80VucsEIG9tQ//HrTlG6sfofA3nRGr6TmkX661uJyu4cMPPh6kXW+aHdq/7+q25KyQhDrXvRL7jw== } - engines: { node: ">= 10.0.0" } + resolution: {integrity: sha512-58ZrlJ1HQY80VucsEIG9tQ//HrTlG6sfofA3nRGr6TmkX661uJyu4cMPPh6kXW+aHdq/7+q25KyQhDrXvRL7jw==} + engines: {node: '>= 10.0.0'} os: [darwin, linux] hasBin: true electron-installer-redhat@3.4.0: - resolution: - { integrity: sha512-gEISr3U32Sgtj+fjxUAlSDo3wyGGq6OBx7rF5UdpIgbnpUvMN4W5uYb0ThpnAZ42VEJh/3aODQXHbFS4f5J3Iw== } - engines: { node: ">= 10.0.0" } + resolution: {integrity: sha512-gEISr3U32Sgtj+fjxUAlSDo3wyGGq6OBx7rF5UdpIgbnpUvMN4W5uYb0ThpnAZ42VEJh/3aODQXHbFS4f5J3Iw==} + engines: {node: '>= 10.0.0'} os: [darwin, linux] hasBin: true + electron-publish@26.15.3: + resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} + electron-to-chromium@1.5.371: - resolution: - { integrity: sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w== } + resolution: {integrity: sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==} electron-winstaller@5.4.0: - resolution: - { integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg== } - engines: { node: ">=8.0.0" } + resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} + engines: {node: '>=8.0.0'} electron@33.4.11: - resolution: - { integrity: sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg== } - engines: { node: ">= 12.20.55" } + resolution: {integrity: sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==} + engines: {node: '>= 12.20.55'} hasBin: true emoji-regex@8.0.0: - resolution: - { integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== } + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} emoji-regex@9.2.2: - resolution: - { integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== } + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} encoding@0.1.13: - resolution: - { integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== } + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} end-of-stream@1.4.5: - resolution: - { integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== } + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} enhanced-resolve@5.23.0: - resolution: - { integrity: sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA== } - engines: { node: ">=10.13.0" } + resolution: {integrity: sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==} + engines: {node: '>=10.13.0'} entities@8.0.0: - resolution: - { integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA== } - engines: { node: ">=20.19.0" } + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} env-paths@2.2.1: - resolution: - { integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== } - engines: { node: ">=6" } + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} err-code@2.0.3: - resolution: - { integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== } + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} error-ex@1.3.4: - resolution: - { integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== } + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} es-define-property@1.0.1: - resolution: - { integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} es-errors@1.3.0: - resolution: - { integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} es-module-lexer@2.1.0: - resolution: - { integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ== } + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} es6-error@4.1.1: - resolution: - { integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== } + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} escalade@3.2.0: - resolution: - { integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== } - engines: { node: ">=6" } + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} escape-string-regexp@1.0.5: - resolution: - { integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== } - engines: { node: ">=0.8.0" } + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} escape-string-regexp@4.0.0: - resolution: - { integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== } - engines: { node: ">=10" } + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} eslint-scope@5.1.1: - resolution: - { integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== } - engines: { node: ">=8.0.0" } + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} esrecurse@4.3.0: - resolution: - { integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== } - engines: { node: ">=4.0" } + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} estraverse@4.3.0: - resolution: - { integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== } - engines: { node: ">=4.0" } + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} estraverse@5.3.0: - resolution: - { integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== } - engines: { node: ">=4.0" } + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} estree-walker@3.0.3: - resolution: - { integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== } + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} eta@3.5.0: - resolution: - { integrity: sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug== } - engines: { node: ">=6.0.0" } + resolution: {integrity: sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==} + engines: {node: '>=6.0.0'} eventemitter3@5.0.4: - resolution: - { integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== } + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} events@3.3.0: - resolution: - { integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== } - engines: { node: ">=0.8.x" } + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} execa@1.0.0: - resolution: - { integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== } - engines: { node: ">=6" } + resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} + engines: {node: '>=6'} expect-type@1.3.0: - resolution: - { integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== } - engines: { node: ">=12.0.0" } + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} exponential-backoff@3.1.3: - resolution: - { integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA== } + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} external-editor@3.1.0: - resolution: - { integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== } - engines: { node: ">=4" } + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} extract-zip@2.0.1: - resolution: - { integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== } - engines: { node: ">= 10.17.0" } + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} hasBin: true fast-deep-equal@3.1.3: - resolution: - { integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== } + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} fast-glob@3.3.3: - resolution: - { integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== } - engines: { node: ">=8.6.0" } + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} fast-uri@3.1.2: - resolution: - { integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ== } + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fastq@1.20.1: - resolution: - { integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw== } + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} fd-slicer@1.1.0: - resolution: - { integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g== } + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} fdir@6.5.0: - resolution: - { integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== } - engines: { node: ">=12.0.0" } + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: picomatch: optional: true + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + filename-reserved-regex@2.0.0: - resolution: - { integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ== } - engines: { node: ">=4" } + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} filenamify@4.3.0: - resolution: - { integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg== } - engines: { node: ">=8" } + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} fill-range@7.1.1: - resolution: - { integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== } - engines: { node: ">=8" } + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} find-up@2.1.0: - resolution: - { integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ== } - engines: { node: ">=4" } + resolution: {integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==} + engines: {node: '>=4'} find-up@5.0.0: - resolution: - { integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== } - engines: { node: ">=10" } + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} flora-colossus@2.0.0: - resolution: - { integrity: sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA== } - engines: { node: ">= 12" } + resolution: {integrity: sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA==} + engines: {node: '>= 12'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} fs-extra@10.1.0: - resolution: - { integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== } - engines: { node: ">=12" } + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} + engines: {node: '>=14.14'} fs-extra@11.3.5: - resolution: - { integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg== } - engines: { node: ">=14.14" } + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} fs-extra@7.0.1: - resolution: - { integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== } - engines: { node: ">=6 <7 || >=8" } + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} fs-extra@8.1.0: - resolution: - { integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== } - engines: { node: ">=6 <7 || >=8" } + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} fs-extra@9.1.0: - resolution: - { integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== } - engines: { node: ">=10" } + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} fs-minipass@2.1.0: - resolution: - { integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== } - engines: { node: ">= 8" } + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} fs.realpath@1.0.0: - resolution: - { integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== } + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} fsevents@2.3.2: - resolution: - { integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== } - engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] fsevents@2.3.3: - resolution: - { integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== } - engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] function-bind@1.1.2: - resolution: - { integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== } + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} galactus@1.0.0: - resolution: - { integrity: sha512-R1fam6D4CyKQGNlvJne4dkNF+PvUUl7TAJInvTGa9fti9qAv95quQz29GXapA4d8Ec266mJJxFVh82M4GIIGDQ== } - engines: { node: ">= 12" } + resolution: {integrity: sha512-R1fam6D4CyKQGNlvJne4dkNF+PvUUl7TAJInvTGa9fti9qAv95quQz29GXapA4d8Ec266mJJxFVh82M4GIIGDQ==} + engines: {node: '>= 12'} gar@1.0.4: - resolution: - { integrity: sha512-w4n9cPWyP7aHxKxYHFQMegj7WIAsL/YX/C4Bs5Rr8s1H9M1rNtRWRsw+ovYMkXDQ5S4ZbYHsHAPmevPjPgw44w== } + resolution: {integrity: sha512-w4n9cPWyP7aHxKxYHFQMegj7WIAsL/YX/C4Bs5Rr8s1H9M1rNtRWRsw+ovYMkXDQ5S4ZbYHsHAPmevPjPgw44w==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. gensync@1.0.0-beta.2: - resolution: - { integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== } - engines: { node: ">=6.9.0" } + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} get-caller-file@2.0.5: - resolution: - { integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== } - engines: { node: 6.* || 8.* || >= 10.* } + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} get-folder-size@2.0.1: - resolution: - { integrity: sha512-+CEb+GDCM7tkOS2wdMKTn9vU7DgnKUTuDlehkNJKNSovdCOVxs14OfKCk4cvSaR3za4gj+OBdl9opPN9xrJ0zA== } + resolution: {integrity: sha512-+CEb+GDCM7tkOS2wdMKTn9vU7DgnKUTuDlehkNJKNSovdCOVxs14OfKCk4cvSaR3za4gj+OBdl9opPN9xrJ0zA==} hasBin: true + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-nonce@1.0.1: - resolution: - { integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== } - engines: { node: ">=6" } + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} get-package-info@1.0.0: - resolution: - { integrity: sha512-SCbprXGAPdIhKAXiG+Mk6yeoFH61JlYunqdFQFHDtLjJlDjFf6x07dsS8acO+xWt52jpdVo49AlVDnUVK1sDNw== } - engines: { node: ">= 4.0" } + resolution: {integrity: sha512-SCbprXGAPdIhKAXiG+Mk6yeoFH61JlYunqdFQFHDtLjJlDjFf6x07dsS8acO+xWt52jpdVo49AlVDnUVK1sDNw==} + engines: {node: '>= 4.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} get-stream@4.1.0: - resolution: - { integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== } - engines: { node: ">=6" } + resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} + engines: {node: '>=6'} get-stream@5.2.0: - resolution: - { integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== } - engines: { node: ">=8" } + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} github-url-to-object@4.0.6: - resolution: - { integrity: sha512-NaqbYHMUAlPcmWFdrAB7bcxrNIiiJWJe8s/2+iOc9vlcHlwHqSGrPk+Yi3nu6ebTwgsZEa7igz+NH2vEq3gYwQ== } + resolution: {integrity: sha512-NaqbYHMUAlPcmWFdrAB7bcxrNIiiJWJe8s/2+iOc9vlcHlwHqSGrPk+Yi3nu6ebTwgsZEa7igz+NH2vEq3gYwQ==} glob-parent@5.1.2: - resolution: - { integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== } - engines: { node: ">= 6" } + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} glob-to-regexp@0.4.1: - resolution: - { integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== } + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} glob@7.2.3: - resolution: - { integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== } + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: - resolution: - { integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== } - engines: { node: ">=12" } + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: - resolution: - { integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q== } - engines: { node: ">=10.0" } + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} global-dirs@3.0.1: - resolution: - { integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA== } - engines: { node: ">=10" } + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} globalthis@1.0.4: - resolution: - { integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} gopd@1.2.0: - resolution: - { integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} got@11.8.6: - resolution: - { integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g== } - engines: { node: ">=10.19.0" } + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} graceful-fs@4.2.11: - resolution: - { integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== } + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} has-flag@4.0.0: - resolution: - { integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== } - engines: { node: ">=8" } + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} has-property-descriptors@1.0.2: - resolution: - { integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== } + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} hasown@2.0.4: - resolution: - { integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} hosted-git-info@2.8.9: - resolution: - { integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== } + resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} html-encoding-sniffer@6.0.0: - resolution: - { integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg== } - engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} http-cache-semantics@4.2.0: - resolution: - { integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ== } + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} http-proxy-agent@5.0.0: - resolution: - { integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== } - engines: { node: ">= 6" } + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} http2-wrapper@1.0.3: - resolution: - { integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg== } - engines: { node: ">=10.19.0" } + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} https-proxy-agent@5.0.1: - resolution: - { integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== } - engines: { node: ">= 6" } + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} https-proxy-agent@7.0.6: - resolution: - { integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== } - engines: { node: ">= 14" } + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} humanize-ms@1.2.1: - resolution: - { integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== } + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} iconv-lite@0.4.24: - resolution: - { integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} iconv-lite@0.6.3: - resolution: - { integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} ieee754@1.2.1: - resolution: - { integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== } + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} imurmurhash@0.1.4: - resolution: - { integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== } - engines: { node: ">=0.8.19" } + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} indent-string@4.0.0: - resolution: - { integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== } - engines: { node: ">=8" } + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} index-to-position@1.2.0: - resolution: - { integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw== } - engines: { node: ">=18" } + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} infer-owner@1.0.4: - resolution: - { integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A== } + resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} inflight@1.0.6: - resolution: - { integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== } + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: - resolution: - { integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== } + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} ini@2.0.0: - resolution: - { integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== } - engines: { node: ">=10" } + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} interpret@3.1.1: - resolution: - { integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ== } - engines: { node: ">=10.13.0" } + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} ip-address@10.2.0: - resolution: - { integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA== } - engines: { node: ">= 12" } + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} is-arrayish@0.2.1: - resolution: - { integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== } + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} is-core-module@2.16.2: - resolution: - { integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA== } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} is-extglob@2.1.1: - resolution: - { integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} is-fullwidth-code-point@3.0.0: - resolution: - { integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== } - engines: { node: ">=8" } + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} is-fullwidth-code-point@4.0.0: - resolution: - { integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== } - engines: { node: ">=12" } + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} is-glob@4.0.3: - resolution: - { integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} is-interactive@1.0.0: - resolution: - { integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== } - engines: { node: ">=8" } + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} is-lambda@1.0.1: - resolution: - { integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ== } + resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} is-number@7.0.0: - resolution: - { integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== } - engines: { node: ">=0.12.0" } + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} is-potential-custom-element-name@1.0.1: - resolution: - { integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== } + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} is-stream@1.1.0: - resolution: - { integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} is-unicode-supported@0.1.0: - resolution: - { integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== } - engines: { node: ">=10" } + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} is-url@1.2.4: - resolution: - { integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww== } + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} isbinaryfile@4.0.10: - resolution: - { integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw== } - engines: { node: ">= 8.0.0" } + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} isbot@5.1.42: - resolution: - { integrity: sha512-/SXsVh7KpPRISrD4ffrGSxnTLlUBzEQUfWIusaJPrpJ93FW1P0YEZri5vAUkFsA0m2HRUhQRQadk2wJ+EeKowQ== } - engines: { node: ">=18" } + resolution: {integrity: sha512-/SXsVh7KpPRISrD4ffrGSxnTLlUBzEQUfWIusaJPrpJ93FW1P0YEZri5vAUkFsA0m2HRUhQRQadk2wJ+EeKowQ==} + engines: {node: '>=18'} isexe@2.0.0: - resolution: - { integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== } + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true jest-worker@27.5.1: - resolution: - { integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== } - engines: { node: ">= 10.13.0" } + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} jiti@2.7.0: - resolution: - { integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ== } + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true js-levenshtein@1.1.6: - resolution: - { integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} js-tokens@4.0.0: - resolution: - { integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== } + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} js-yaml@4.1.1: - resolution: - { integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== } + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsdom@29.1.1: - resolution: - { integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q== } - engines: { node: ^20.19.0 || ^22.13.0 || >=24.0.0 } + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 peerDependenciesMeta: @@ -3200,1159 +2925,1054 @@ packages: optional: true jsesc@3.1.0: - resolution: - { integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== } - engines: { node: ">=6" } + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} hasBin: true json-buffer@3.0.1: - resolution: - { integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== } + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} json-schema-traverse@1.0.0: - resolution: - { integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== } + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} json-stringify-safe@5.0.1: - resolution: - { integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== } + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} json5@2.2.3: - resolution: - { integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== } - engines: { node: ">=6" } + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} hasBin: true jsonfile@4.0.0: - resolution: - { integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== } + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} jsonfile@6.2.1: - resolution: - { integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q== } + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} junk@3.1.0: - resolution: - { integrity: sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ== } - engines: { node: ">=8" } + resolution: {integrity: sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==} + engines: {node: '>=8'} keyv@4.5.4: - resolution: - { integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== } + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} lightningcss-android-arm64@1.32.0: - resolution: - { integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] lightningcss-darwin-arm64@1.32.0: - resolution: - { integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] lightningcss-darwin-x64@1.32.0: - resolution: - { integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] lightningcss-freebsd-x64@1.32.0: - resolution: - { integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: - { integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] lightningcss-linux-arm64-gnu@1.32.0: - resolution: - { integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: - resolution: - { integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: - resolution: - { integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: - resolution: - { integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: - resolution: - { integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] lightningcss-win32-x64-msvc@1.32.0: - resolution: - { integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] lightningcss@1.32.0: - resolution: - { integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} listr2@7.0.2: - resolution: - { integrity: sha512-rJysbR9GKIalhTbVL2tYbF2hVyDnrf7pFUZBwjPaMIdadYHmeT+EVi/Bu3qd7ETQPahTotg2WRCatXwRBW554g== } - engines: { node: ">=16.0.0" } + resolution: {integrity: sha512-rJysbR9GKIalhTbVL2tYbF2hVyDnrf7pFUZBwjPaMIdadYHmeT+EVi/Bu3qd7ETQPahTotg2WRCatXwRBW554g==} + engines: {node: '>=16.0.0'} load-json-file@2.0.0: - resolution: - { integrity: sha512-3p6ZOGNbiX4CdvEd1VcE6yi78UrGNpjHO33noGwHCnT/o2fyllJDepsm8+mFFv/DvtwFHht5HIHSyOy5a+ChVQ== } - engines: { node: ">=4" } + resolution: {integrity: sha512-3p6ZOGNbiX4CdvEd1VcE6yi78UrGNpjHO33noGwHCnT/o2fyllJDepsm8+mFFv/DvtwFHht5HIHSyOy5a+ChVQ==} + engines: {node: '>=4'} loader-runner@4.3.2: - resolution: - { integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w== } - engines: { node: ">=6.11.5" } + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} + engines: {node: '>=6.11.5'} locate-path@2.0.0: - resolution: - { integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA== } - engines: { node: ">=4" } + resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==} + engines: {node: '>=4'} locate-path@6.0.0: - resolution: - { integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== } - engines: { node: ">=10" } + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} lodash.get@4.4.2: - resolution: - { integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== } + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. lodash@4.18.1: - resolution: - { integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== } + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} log-symbols@4.1.0: - resolution: - { integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== } - engines: { node: ">=10" } + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} log-update@5.0.1: - resolution: - { integrity: sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw== } - engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + resolution: {integrity: sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} lowercase-keys@2.0.0: - resolution: - { integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== } - engines: { node: ">=8" } + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} lru-cache@11.5.1: - resolution: - { integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A== } - engines: { node: 20 || >=22 } + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} lru-cache@5.1.1: - resolution: - { integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== } + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} lru-cache@7.18.3: - resolution: - { integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== } - engines: { node: ">=12" } + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} lucide-react@1.17.0: - resolution: - { integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w== } + resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 lz-string@1.5.0: - resolution: - { integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== } + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true magic-string@0.30.21: - resolution: - { integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== } + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} make-fetch-happen@10.2.1: - resolution: - { integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w== } - engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} map-age-cleaner@0.1.3: - resolution: - { integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== } - engines: { node: ">=6" } + resolution: {integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==} + engines: {node: '>=6'} matcher@3.0.0: - resolution: - { integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng== } - engines: { node: ">=10" } + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} mdn-data@2.27.1: - resolution: - { integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ== } + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} mem@4.3.0: - resolution: - { integrity: sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w== } - engines: { node: ">=6" } + resolution: {integrity: sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==} + engines: {node: '>=6'} merge-stream@2.0.0: - resolution: - { integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== } + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} merge2@1.4.1: - resolution: - { integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== } - engines: { node: ">= 8" } + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} micromatch@4.0.8: - resolution: - { integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== } - engines: { node: ">=8.6" } + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} mime-db@1.52.0: - resolution: - { integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} mime-db@1.54.0: - resolution: - { integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} mime-types@2.1.35: - resolution: - { integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true mimic-fn@2.1.0: - resolution: - { integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== } - engines: { node: ">=6" } + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} mimic-response@1.0.1: - resolution: - { integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== } - engines: { node: ">=4" } + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} mimic-response@3.1.0: - resolution: - { integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== } - engines: { node: ">=10" } + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} min-indent@1.0.1: - resolution: - { integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== } - engines: { node: ">=4" } + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} minimatch@3.1.5: - resolution: - { integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== } + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} minimatch@5.1.9: - resolution: - { integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw== } - engines: { node: ">=10" } + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} minimatch@9.0.9: - resolution: - { integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== } - engines: { node: ">=16 || 14 >=14.17" } + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} minimist@1.2.8: - resolution: - { integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== } + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} minipass-collect@1.0.2: - resolution: - { integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA== } - engines: { node: ">= 8" } + resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} + engines: {node: '>= 8'} minipass-fetch@2.1.2: - resolution: - { integrity: sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA== } - engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + resolution: {integrity: sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} minipass-flush@1.0.7: - resolution: - { integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA== } - engines: { node: ">= 8" } + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} minipass-pipeline@1.2.4: - resolution: - { integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== } - engines: { node: ">=8" } + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} minipass-sized@1.0.3: - resolution: - { integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g== } - engines: { node: ">=8" } + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} minipass@3.3.6: - resolution: - { integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== } - engines: { node: ">=8" } + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} minipass@5.0.0: - resolution: - { integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== } - engines: { node: ">=8" } + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} minizlib@2.1.2: - resolution: - { integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== } - engines: { node: ">= 8" } + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} mkdirp@0.5.6: - resolution: - { integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== } + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true mkdirp@1.0.4: - resolution: - { integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== } - engines: { node: ">=10" } + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} hasBin: true ms@2.0.0: - resolution: - { integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== } + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} ms@2.1.3: - resolution: - { integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== } + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} mute-stream@1.0.0: - resolution: - { integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA== } - engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 } + resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} nanoid@3.3.12: - resolution: - { integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ== } - engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true negotiator@0.6.4: - resolution: - { integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} neo-async@2.6.2: - resolution: - { integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== } + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} nice-try@1.0.5: - resolution: - { integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== } + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} node-abi@3.92.0: - resolution: - { integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ== } - engines: { node: ">=10" } + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + + node-abi@4.31.0: + resolution: {integrity: sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==} + engines: {node: '>=22.12.0'} node-api-version@0.2.1: - resolution: - { integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q== } + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} node-fetch@2.7.0: - resolution: - { integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== } - engines: { node: 4.x || >=6.0.0 } + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} peerDependencies: encoding: ^0.1.0 peerDependenciesMeta: encoding: optional: true + node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-releases@2.0.47: - resolution: - { integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og== } - engines: { node: ">=18" } + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} nopt@6.0.0: - resolution: - { integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g== } - engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + hasBin: true + + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true normalize-package-data@2.5.0: - resolution: - { integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== } + resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} normalize-url@6.1.0: - resolution: - { integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== } - engines: { node: ">=10" } + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} npm-run-path@2.0.2: - resolution: - { integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw== } - engines: { node: ">=4" } + resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} + engines: {node: '>=4'} object-keys@1.1.1: - resolution: - { integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} obug@2.1.2: - resolution: - { integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg== } - engines: { node: ">=12.20.0" } + resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} + engines: {node: '>=12.20.0'} once@1.4.0: - resolution: - { integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== } + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} onetime@5.1.2: - resolution: - { integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== } - engines: { node: ">=6" } + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} openapi-fetch@0.17.0: - resolution: - { integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig== } + resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} openapi-typescript-helpers@0.1.0: - resolution: - { integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw== } + resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} openapi-typescript@7.13.0: - resolution: - { integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ== } + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} hasBin: true peerDependencies: typescript: ^5.x ora@5.4.1: - resolution: - { integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== } - engines: { node: ">=10" } + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} os-tmpdir@1.0.2: - resolution: - { integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} p-cancelable@2.1.1: - resolution: - { integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg== } - engines: { node: ">=8" } + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} p-defer@1.0.0: - resolution: - { integrity: sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw== } - engines: { node: ">=4" } + resolution: {integrity: sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==} + engines: {node: '>=4'} p-finally@1.0.0: - resolution: - { integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== } - engines: { node: ">=4" } + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} p-is-promise@2.1.0: - resolution: - { integrity: sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg== } - engines: { node: ">=6" } + resolution: {integrity: sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==} + engines: {node: '>=6'} p-limit@1.3.0: - resolution: - { integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== } - engines: { node: ">=4" } + resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==} + engines: {node: '>=4'} p-limit@3.1.0: - resolution: - { integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== } - engines: { node: ">=10" } + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} p-locate@2.0.0: - resolution: - { integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg== } - engines: { node: ">=4" } + resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} + engines: {node: '>=4'} p-locate@5.0.0: - resolution: - { integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== } - engines: { node: ">=10" } + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} p-map@4.0.0: - resolution: - { integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== } - engines: { node: ">=10" } + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} p-try@1.0.0: - resolution: - { integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww== } - engines: { node: ">=4" } + resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} + engines: {node: '>=4'} parse-author@2.0.0: - resolution: - { integrity: sha512-yx5DfvkN8JsHL2xk2Os9oTia467qnvRgey4ahSm2X8epehBLx/gWLcy5KI+Y36ful5DzGbCS6RazqZGgy1gHNw== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-yx5DfvkN8JsHL2xk2Os9oTia467qnvRgey4ahSm2X8epehBLx/gWLcy5KI+Y36ful5DzGbCS6RazqZGgy1gHNw==} + engines: {node: '>=0.10.0'} parse-json@2.2.0: - resolution: - { integrity: sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==} + engines: {node: '>=0.10.0'} parse-json@8.3.0: - resolution: - { integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ== } - engines: { node: ">=18" } + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} parse5@8.0.1: - resolution: - { integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw== } + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} path-exists@3.0.0: - resolution: - { integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ== } - engines: { node: ">=4" } + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} path-exists@4.0.0: - resolution: - { integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== } - engines: { node: ">=8" } + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} path-is-absolute@1.0.1: - resolution: - { integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} path-key@2.0.1: - resolution: - { integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw== } - engines: { node: ">=4" } + resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} + engines: {node: '>=4'} path-key@3.1.1: - resolution: - { integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== } - engines: { node: ">=8" } + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} path-parse@1.0.7: - resolution: - { integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== } + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} path-type@2.0.0: - resolution: - { integrity: sha512-dUnb5dXUf+kzhC/W/F4e5/SkluXIFf5VUHolW1Eg1irn1hGWjPGdsRcvYJ1nD6lhk8Ir7VM0bHJKsYTx8Jx9OQ== } - engines: { node: ">=4" } + resolution: {integrity: sha512-dUnb5dXUf+kzhC/W/F4e5/SkluXIFf5VUHolW1Eg1irn1hGWjPGdsRcvYJ1nD6lhk8Ir7VM0bHJKsYTx8Jx9OQ==} + engines: {node: '>=4'} pathe@2.0.3: - resolution: - { integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== } + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pe-library@0.4.1: + resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} + engines: {node: '>=12', npm: '>=6'} pe-library@1.0.1: - resolution: - { integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg== } - engines: { node: ">=14", npm: ">=7" } + resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==} + engines: {node: '>=14', npm: '>=7'} pend@1.2.0: - resolution: - { integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== } + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} picocolors@1.1.1: - resolution: - { integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== } + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} picomatch@2.3.2: - resolution: - { integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== } - engines: { node: ">=8.6" } + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} picomatch@4.0.4: - resolution: - { integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== } - engines: { node: ">=12" } + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} pify@2.3.0: - resolution: - { integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pkijs@3.4.0: + resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} + engines: {node: '>=16.0.0'} playwright-core@1.60.0: - resolution: - { integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA== } - engines: { node: ">=18" } + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} hasBin: true playwright@1.60.0: - resolution: - { integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA== } - engines: { node: ">=18" } + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} hasBin: true + plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + plist@3.1.1: - resolution: - { integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA== } - engines: { node: ">=10.4.0" } + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} pluralize@8.0.0: - resolution: - { integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== } - engines: { node: ">=4" } + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} postcss@8.5.15: - resolution: - { integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A== } - engines: { node: ^10 || ^12 || >=14 } + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + posthog-js@1.393.0: + resolution: {integrity: sha512-BNu62XUNkFEIq7ZQJwvgtZIgWUfn0HozVcYHO8P1WMq2Crx+d+/l7TJsO6YHf3aUUiJn+L8L8NX7XgFZxW3/tw==} postject@1.0.0-alpha.6: - resolution: - { integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A== } - engines: { node: ">=14.0.0" } + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} hasBin: true + preact@10.29.2: + resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} + prettier@3.8.4: - resolution: - { integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q== } - engines: { node: ">=14" } + resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} + engines: {node: '>=14'} hasBin: true pretty-format@27.5.1: - resolution: - { integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== } - engines: { node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0 } + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} proc-log@2.0.1: - resolution: - { integrity: sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw== } - engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + resolution: {integrity: sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} progress@2.0.3: - resolution: - { integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== } - engines: { node: ">=0.4.0" } + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} promise-inflight@1.0.1: - resolution: - { integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g== } + resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} peerDependencies: - bluebird: "*" + bluebird: '*' peerDependenciesMeta: bluebird: optional: true promise-retry@2.0.1: - resolution: - { integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g== } - engines: { node: ">=10" } + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} pump@3.0.4: - resolution: - { integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA== } + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} punycode@2.3.1: - resolution: - { integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== } - engines: { node: ">=6" } + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} queue-microtask@1.2.3: - resolution: - { integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== } + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} quick-lru@5.1.1: - resolution: - { integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== } - engines: { node: ">=10" } + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} radix-ui@1.5.0: - resolution: - { integrity: sha512-Nzh2HNpClgB31FBHRqt2xG8XNUfVfQRpf34hACC5PNrXTd5JdXdqOXwLs3BL+D8CNYiNQiJiT8QGr5Q4vq+00w== } + resolution: {integrity: sha512-Nzh2HNpClgB31FBHRqt2xG8XNUfVfQRpf34hACC5PNrXTd5JdXdqOXwLs3BL+D8CNYiNQiJiT8QGr5Q4vq+00w==} peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true - "@types/react-dom": + '@types/react-dom': optional: true react-dom@19.2.7: - resolution: - { integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ== } + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: react: ^19.2.7 react-is@17.0.2: - resolution: - { integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== } + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} react-remove-scroll-bar@2.3.8: - resolution: - { integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q== } - engines: { node: ">=10" } + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: - "@types/react": + '@types/react': optional: true react-remove-scroll@2.7.2: - resolution: - { integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q== } - engines: { node: ">=10" } + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true react-resizable-panels@4.11.2: - resolution: - { integrity: sha512-+kfFbDZ8mygc7g0vxOcDzCVGuwiIUOnILqPoUHo6/uP+Mmyx6HzZU+kj1aOPDlktXuobYbr6BtQekvJwHRX4Eg== } + resolution: {integrity: sha512-+kfFbDZ8mygc7g0vxOcDzCVGuwiIUOnILqPoUHo6/uP+Mmyx6HzZU+kj1aOPDlktXuobYbr6BtQekvJwHRX4Eg==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 react-style-singleton@2.2.3: - resolution: - { integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ== } - engines: { node: ">=10" } + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true react@19.2.7: - resolution: - { integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} read-binary-file-arch@1.0.6: - resolution: - { integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg== } + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} hasBin: true read-pkg-up@2.0.0: - resolution: - { integrity: sha512-1orxQfbWGUiTn9XsPlChs6rLie/AV9jwZTGmu2NZw/CUDJQchXJFYE0Fq5j7+n558T1JhDWLdhyd1Zj+wLY//w== } - engines: { node: ">=4" } + resolution: {integrity: sha512-1orxQfbWGUiTn9XsPlChs6rLie/AV9jwZTGmu2NZw/CUDJQchXJFYE0Fq5j7+n558T1JhDWLdhyd1Zj+wLY//w==} + engines: {node: '>=4'} read-pkg@2.0.0: - resolution: - { integrity: sha512-eFIBOPW7FGjzBuk3hdXEuNSiTZS/xEMlH49HxMyzb0hyPfu4EhVjT2DH32K1hSSmVq4sebAWnZuuY5auISUTGA== } - engines: { node: ">=4" } + resolution: {integrity: sha512-eFIBOPW7FGjzBuk3hdXEuNSiTZS/xEMlH49HxMyzb0hyPfu4EhVjT2DH32K1hSSmVq4sebAWnZuuY5auISUTGA==} + engines: {node: '>=4'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} readable-stream@3.6.2: - resolution: - { integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== } - engines: { node: ">= 6" } + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} readdirp@5.0.0: - resolution: - { integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ== } - engines: { node: ">= 20.19.0" } + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} rechoir@0.8.0: - resolution: - { integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ== } - engines: { node: ">= 10.13.0" } + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} redent@3.0.0: - resolution: - { integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== } - engines: { node: ">=8" } + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} require-directory@2.1.1: - resolution: - { integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} require-from-string@2.0.2: - resolution: - { integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resedit@1.7.2: + resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} + engines: {node: '>=12', npm: '>=6'} resedit@2.0.3: - resolution: - { integrity: sha512-oTeemxwoMuxxTYxXUwjkrOPfngTQehlv0/HoYFNkB4uzsP1Un1A9nI8JQKGOFkxpqkC7qkMs0lUsGrvUlbLNUA== } - engines: { node: ">=14", npm: ">=7" } + resolution: {integrity: sha512-oTeemxwoMuxxTYxXUwjkrOPfngTQehlv0/HoYFNkB4uzsP1Un1A9nI8JQKGOFkxpqkC7qkMs0lUsGrvUlbLNUA==} + engines: {node: '>=14', npm: '>=7'} resolve-alpn@1.2.1: - resolution: - { integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g== } + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} resolve@1.22.12: - resolution: - { integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA== } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} hasBin: true responselike@2.0.1: - resolution: - { integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw== } + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} restore-cursor@3.1.0: - resolution: - { integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== } - engines: { node: ">=8" } + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} restore-cursor@4.0.0: - resolution: - { integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg== } - engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} retry@0.12.0: - resolution: - { integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== } - engines: { node: ">= 4" } + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} reusify@1.1.0: - resolution: - { integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== } - engines: { iojs: ">=1.0.0", node: ">=0.10.0" } + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} rfdc@1.4.1: - resolution: - { integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== } + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} rimraf@2.6.3: - resolution: - { integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== } + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rimraf@3.0.2: - resolution: - { integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== } + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true roarr@2.15.4: - resolution: - { integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A== } - engines: { node: ">=8.0" } + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} rolldown@1.0.3: - resolution: - { integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g== } - engines: { node: ^20.19.0 || >=22.12.0 } + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true run-parallel@1.2.0: - resolution: - { integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== } + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} safe-buffer@5.2.1: - resolution: - { integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== } + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} safer-buffer@2.1.2: - resolution: - { integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== } + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} saxes@6.0.0: - resolution: - { integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== } - engines: { node: ">=v12.22.7" } + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} scheduler@0.27.0: - resolution: - { integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== } + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} schema-utils@4.3.3: - resolution: - { integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA== } - engines: { node: ">= 10.13.0" } + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} semver-compare@1.0.0: - resolution: - { integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== } + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} semver@5.7.2: - resolution: - { integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== } + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true semver@6.3.1: - resolution: - { integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== } + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} hasBin: true semver@7.8.4: - resolution: - { integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA== } - engines: { node: ">=10" } + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} hasBin: true serialize-error@7.0.1: - resolution: - { integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw== } - engines: { node: ">=10" } + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} seroval-plugins@1.5.4: - resolution: - { integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw== } - engines: { node: ">=10" } + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} peerDependencies: seroval: ^1.0 seroval@1.5.4: - resolution: - { integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw== } - engines: { node: ">=10" } + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + engines: {node: '>=10'} shebang-command@1.2.0: - resolution: - { integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} + engines: {node: '>=0.10.0'} shebang-command@2.0.0: - resolution: - { integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== } - engines: { node: ">=8" } + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} shebang-regex@1.0.0: - resolution: - { integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} + engines: {node: '>=0.10.0'} shebang-regex@3.0.0: - resolution: - { integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== } - engines: { node: ">=8" } + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} siginfo@2.0.0: - resolution: - { integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== } + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} signal-exit@3.0.7: - resolution: - { integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== } + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} signal-exit@4.1.0: - resolution: - { integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== } - engines: { node: ">=14" } + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} slice-ansi@5.0.0: - resolution: - { integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ== } - engines: { node: ">=12" } + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} smart-buffer@4.2.0: - resolution: - { integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== } - engines: { node: ">= 6.0.0", npm: ">= 3.0.0" } + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} socks-proxy-agent@7.0.0: - resolution: - { integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww== } - engines: { node: ">= 10" } + resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==} + engines: {node: '>= 10'} socks@2.8.9: - resolution: - { integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw== } - engines: { node: ">= 10.0.0", npm: ">= 3.0.0" } + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} source-map-js@1.2.1: - resolution: - { integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} source-map-support@0.5.21: - resolution: - { integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== } + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} source-map@0.6.1: - resolution: - { integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} spdx-correct@3.2.0: - resolution: - { integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA== } + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} spdx-exceptions@2.5.0: - resolution: - { integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w== } + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} spdx-expression-parse@3.0.1: - resolution: - { integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== } + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} spdx-license-ids@3.0.23: - resolution: - { integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw== } + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} sprintf-js@1.1.3: - resolution: - { integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== } + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} ssri@9.0.1: - resolution: - { integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q== } - engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} stackback@0.0.2: - resolution: - { integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== } + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + stat-mode@1.0.0: + resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} + engines: {node: '>= 6'} std-env@4.1.0: - resolution: - { integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ== } + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} string-width@4.2.3: - resolution: - { integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== } - engines: { node: ">=8" } + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} string-width@5.1.2: - resolution: - { integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== } - engines: { node: ">=12" } + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} string_decoder@1.3.0: - resolution: - { integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== } + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} strip-ansi@6.0.1: - resolution: - { integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== } - engines: { node: ">=8" } + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} strip-ansi@7.2.0: - resolution: - { integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== } - engines: { node: ">=12" } + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} strip-bom@3.0.0: - resolution: - { integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== } - engines: { node: ">=4" } + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} strip-eof@1.0.0: - resolution: - { integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} + engines: {node: '>=0.10.0'} strip-indent@3.0.0: - resolution: - { integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== } - engines: { node: ">=8" } + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} strip-outer@1.0.1: - resolution: - { integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} sumchecker@3.0.1: - resolution: - { integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg== } - engines: { node: ">= 8.0" } + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} supports-color@10.2.2: - resolution: - { integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g== } - engines: { node: ">=18" } + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} supports-color@7.2.0: - resolution: - { integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== } - engines: { node: ">=8" } + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} supports-color@8.1.1: - resolution: - { integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== } - engines: { node: ">=10" } + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} supports-preserve-symlinks-flag@1.0.0: - resolution: - { integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} symbol-tree@3.2.4: - resolution: - { integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== } + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} tailwind-merge@3.6.0: - resolution: - { integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w== } + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} tailwindcss@4.3.0: - resolution: - { integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q== } + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} tapable@2.3.3: - resolution: - { integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A== } - engines: { node: ">=6" } + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} tar@6.2.1: - resolution: - { integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== } - engines: { node: ">=10" } + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + tar@7.5.16: + resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + engines: {node: '>=18'} + + temp-file@3.4.0: + resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + temp@0.9.4: - resolution: - { integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA== } - engines: { node: ">=6.0.0" } + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} terser-webpack-plugin@5.6.1: - resolution: - { integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ== } - engines: { node: ">= 10.13.0" } + resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} + engines: {node: '>= 10.13.0'} peerDependencies: - "@minify-html/node": "*" - "@swc/core": "*" - "@swc/css": "*" - "@swc/html": "*" - clean-css: "*" - cssnano: "*" - csso: "*" - esbuild: "*" - html-minifier-terser: "*" - lightningcss: "*" - postcss: "*" - uglify-js: "*" + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' webpack: ^5.1.0 peerDependenciesMeta: - "@minify-html/node": + '@minify-html/node': optional: true - "@swc/core": + '@swc/core': optional: true - "@swc/css": + '@swc/css': optional: true - "@swc/html": + '@swc/html': optional: true clean-css: optional: true @@ -4372,237 +3992,211 @@ packages: optional: true terser@5.48.0: - resolution: - { integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q== } - engines: { node: ">=10" } + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} hasBin: true + tiny-async-pool@1.3.0: + resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tiny-each-async@2.0.3: - resolution: - { integrity: sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA== } + resolution: {integrity: sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==} tinybench@2.9.0: - resolution: - { integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== } + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} tinyexec@1.2.4: - resolution: - { integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg== } - engines: { node: ">=18" } + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} tinyglobby@0.2.17: - resolution: - { integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== } - engines: { node: ">=12.0.0" } + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} tinyrainbow@3.1.0: - resolution: - { integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw== } - engines: { node: ">=14.0.0" } + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} tldts-core@7.4.2: - resolution: - { integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA== } + resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} tldts@7.4.2: - resolution: - { integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw== } + resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} hasBin: true tmp-promise@3.0.3: - resolution: - { integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ== } + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} tmp@0.0.33: - resolution: - { integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== } - engines: { node: ">=0.6.0" } + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} tmp@0.2.7: - resolution: - { integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw== } - engines: { node: ">=14.14" } + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} to-regex-range@5.0.1: - resolution: - { integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== } - engines: { node: ">=8.0" } + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} tough-cookie@6.0.1: - resolution: - { integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw== } - engines: { node: ">=16" } + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} tr46@0.0.3: - resolution: - { integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== } + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} tr46@6.0.0: - resolution: - { integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw== } - engines: { node: ">=20" } + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} trim-repeated@1.0.0: - resolution: - { integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} tslib@2.8.1: - resolution: - { integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== } + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} type-fest@0.13.1: - resolution: - { integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg== } - engines: { node: ">=10" } + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} type-fest@0.21.3: - resolution: - { integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== } - engines: { node: ">=10" } + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} type-fest@1.4.0: - resolution: - { integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== } - engines: { node: ">=10" } + resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} + engines: {node: '>=10'} type-fest@4.41.0: - resolution: - { integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== } - engines: { node: ">=16" } + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} typescript@5.4.5: - resolution: - { integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== } - engines: { node: ">=14.17" } + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} hasBin: true typescript@5.9.3: - resolution: - { integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== } - engines: { node: ">=14.17" } + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} hasBin: true undici-types@6.21.0: - resolution: - { integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== } + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} undici-types@7.24.6: - resolution: - { integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg== } + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + engines: {node: '>=18.17'} undici@7.27.2: - resolution: - { integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA== } - engines: { node: ">=20.18.1" } + resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} + engines: {node: '>=20.18.1'} unique-filename@2.0.1: - resolution: - { integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A== } - engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} unique-slug@3.0.0: - resolution: - { integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w== } - engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + resolution: {integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} universal-user-agent@6.0.1: - resolution: - { integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ== } + resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} universalify@0.1.2: - resolution: - { integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== } - engines: { node: ">= 4.0.0" } + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} universalify@2.0.1: - resolution: - { integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== } - engines: { node: ">= 10.0.0" } + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} unplugin@3.0.0: - resolution: - { integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg== } - engines: { node: ^20.19.0 || >=22.12.0 } + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + + unzipper@0.12.5: + resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} update-browserslist-db@1.2.3: - resolution: - { integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== } + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: - browserslist: ">= 4.21.0" + browserslist: '>= 4.21.0' update-electron-app@3.2.0: - resolution: - { integrity: sha512-l2e7bzsW+rw70pfyyQeA9E/ofpNY2ZS99XuYxD2qWL4fEy3qMjpqwwgB0me7ESpGogIQE1CM0SaDvKGsK4Jg3Q== } + resolution: {integrity: sha512-l2e7bzsW+rw70pfyyQeA9E/ofpNY2ZS99XuYxD2qWL4fEy3qMjpqwwgB0me7ESpGogIQE1CM0SaDvKGsK4Jg3Q==} uri-js-replace@1.0.1: - resolution: - { integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g== } + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} use-callback-ref@1.3.3: - resolution: - { integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg== } - engines: { node: ">=10" } + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true use-sidecar@1.1.3: - resolution: - { integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ== } - engines: { node: ">=10" } + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} peerDependencies: - "@types/react": "*" + '@types/react': '*' react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: - "@types/react": + '@types/react': optional: true use-sync-external-store@1.6.0: - resolution: - { integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== } + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 username@5.1.0: - resolution: - { integrity: sha512-PCKbdWw85JsYMvmCv5GH3kXmM66rCd9m1hBEDutPNv94b/pqCMT4NtcKyeWYvLFiE8b+ha1Jdl8XAaUdPn5QTg== } - engines: { node: ">=8" } + resolution: {integrity: sha512-PCKbdWw85JsYMvmCv5GH3kXmM66rCd9m1hBEDutPNv94b/pqCMT4NtcKyeWYvLFiE8b+ha1Jdl8XAaUdPn5QTg==} + engines: {node: '>=8'} + + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} util-deprecate@1.0.2: - resolution: - { integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== } + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} validate-npm-package-license@3.0.4: - resolution: - { integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== } + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} vite@8.0.16: - resolution: - { integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw== } - engines: { node: ^20.19.0 || >=22.12.0 } + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - "@types/node": ^20.19.0 || >=22.12.0 - "@vitejs/devtools": ^0.1.18 + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 esbuild: ^0.27.0 || ^0.28.0 - jiti: ">=1.21.0" + jiti: '>=1.21.0' less: ^4.0.0 sass: ^1.70.0 sass-embedded: ^1.70.0 - stylus: ">=0.54.8" + stylus: '>=0.54.8' sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: - "@types/node": + '@types/node': optional: true - "@vitejs/devtools": + '@vitejs/devtools': optional: true esbuild: optional: true @@ -4626,41 +4220,40 @@ packages: optional: true vitest@4.1.8: - resolution: - { integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig== } - engines: { node: ^20.0.0 || ^22.0.0 || >=24.0.0 } + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: - "@edge-runtime/vm": "*" - "@opentelemetry/api": ^1.9.0 - "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.1.8 - "@vitest/browser-preview": 4.1.8 - "@vitest/browser-webdriverio": 4.1.8 - "@vitest/coverage-istanbul": 4.1.8 - "@vitest/coverage-v8": 4.1.8 - "@vitest/ui": 4.1.8 - happy-dom: "*" - jsdom: "*" + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: - "@edge-runtime/vm": + '@edge-runtime/vm': optional: true - "@opentelemetry/api": + '@opentelemetry/api': optional: true - "@types/node": + '@types/node': optional: true - "@vitest/browser-playwright": + '@vitest/browser-playwright': optional: true - "@vitest/browser-preview": + '@vitest/browser-preview': optional: true - "@vitest/browser-webdriverio": + '@vitest/browser-webdriverio': optional: true - "@vitest/coverage-istanbul": + '@vitest/coverage-istanbul': optional: true - "@vitest/coverage-v8": + '@vitest/coverage-v8': optional: true - "@vitest/ui": + '@vitest/ui': optional: true happy-dom: optional: true @@ -4668,183 +4261,168 @@ packages: optional: true w3c-xmlserializer@5.0.0: - resolution: - { integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== } - engines: { node: ">=18" } + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} watchpack@2.5.1: - resolution: - { integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg== } - engines: { node: ">=10.13.0" } + resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + engines: {node: '>=10.13.0'} wcwidth@1.0.1: - resolution: - { integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg== } + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-vitals@5.3.0: + resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==} + + webcrypto-core@1.9.2: + resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} webidl-conversions@3.0.1: - resolution: - { integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== } + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} webidl-conversions@8.0.1: - resolution: - { integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ== } - engines: { node: ">=20" } + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} webpack-sources@3.5.0: - resolution: - { integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ== } - engines: { node: ">=10.13.0" } + resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} + engines: {node: '>=10.13.0'} webpack-virtual-modules@0.6.2: - resolution: - { integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ== } + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} webpack@5.107.2: - resolution: - { integrity: sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ== } - engines: { node: ">=10.13.0" } + resolution: {integrity: sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==} + engines: {node: '>=10.13.0'} hasBin: true peerDependencies: - webpack-cli: "*" + webpack-cli: '*' peerDependenciesMeta: webpack-cli: optional: true whatwg-mimetype@5.0.0: - resolution: - { integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw== } - engines: { node: ">=20" } + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} whatwg-url@16.0.1: - resolution: - { integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw== } - engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} whatwg-url@5.0.0: - resolution: - { integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== } + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} which@1.3.1: - resolution: - { integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== } + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true which@2.0.2: - resolution: - { integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== } - engines: { node: ">= 8" } + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true why-is-node-running@2.3.0: - resolution: - { integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== } - engines: { node: ">=8" } + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} hasBin: true word-wrap@1.2.5: - resolution: - { integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} wrap-ansi@6.2.0: - resolution: - { integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== } - engines: { node: ">=8" } + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} wrap-ansi@7.0.0: - resolution: - { integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== } - engines: { node: ">=10" } + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} wrap-ansi@8.1.0: - resolution: - { integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== } - engines: { node: ">=12" } + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} wrappy@1.0.2: - resolution: - { integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== } + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} xml-name-validator@5.0.0: - resolution: - { integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== } - engines: { node: ">=18" } + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} xmlbuilder@15.1.1: - resolution: - { integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg== } - engines: { node: ">=8.0" } + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} xmlchars@2.2.0: - resolution: - { integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== } + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} y18n@5.0.8: - resolution: - { integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== } - engines: { node: ">=10" } + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} yallist@3.1.1: - resolution: - { integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== } + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} yallist@4.0.0: - resolution: - { integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== } + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} yaml-ast-parser@0.0.43: - resolution: - { integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A== } + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} yargs-parser@20.2.9: - resolution: - { integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== } - engines: { node: ">=10" } + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} yargs-parser@21.1.1: - resolution: - { integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== } - engines: { node: ">=12" } + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} yargs@16.2.0: - resolution: - { integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== } - engines: { node: ">=10" } + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} yargs@17.7.2: - resolution: - { integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== } - engines: { node: ">=12" } + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} yauzl@2.10.0: - resolution: - { integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g== } + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} yocto-queue@0.1.0: - resolution: - { integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== } - engines: { node: ">=10" } + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} yoctocolors-cjs@2.1.3: - resolution: - { integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw== } - engines: { node: ">=18" } + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} zod@4.4.3: - resolution: - { integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ== } + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} zustand@5.0.14: - resolution: - { integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g== } - engines: { node: ">=12.20.0" } + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} peerDependencies: - "@types/react": ">=18.0.0" - immer: ">=9.0.6" - react: ">=18.0.0" - use-sync-external-store: ">=1.2.0" + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' peerDependenciesMeta: - "@types/react": + '@types/react': optional: true immer: optional: true @@ -4854,48 +4432,49 @@ packages: optional: true snapshots: - "@adobe/css-tools@4.5.0": {} - "@asamuzakjp/css-color@5.1.11": + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@5.1.11': dependencies: - "@asamuzakjp/generational-cache": 1.0.1 - "@csstools/css-calc": 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - "@csstools/css-color-parser": 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - "@csstools/css-parser-algorithms": 4.0.0(@csstools/css-tokenizer@4.0.0) - "@csstools/css-tokenizer": 4.0.0 + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - "@asamuzakjp/dom-selector@7.1.1": + '@asamuzakjp/dom-selector@7.1.1': dependencies: - "@asamuzakjp/generational-cache": 1.0.1 - "@asamuzakjp/nwsapi": 2.3.9 + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 bidi-js: 1.0.3 css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 - "@asamuzakjp/generational-cache@1.0.1": {} + '@asamuzakjp/generational-cache@1.0.1': {} - "@asamuzakjp/nwsapi@2.3.9": {} + '@asamuzakjp/nwsapi@2.3.9': {} - "@babel/code-frame@7.29.7": + '@babel/code-frame@7.29.7': dependencies: - "@babel/helper-validator-identifier": 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 - "@babel/compat-data@7.29.7": {} + '@babel/compat-data@7.29.7': {} - "@babel/core@7.29.7": + '@babel/core@7.29.7': dependencies: - "@babel/code-frame": 7.29.7 - "@babel/generator": 7.29.7 - "@babel/helper-compilation-targets": 7.29.7 - "@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7) - "@babel/helpers": 7.29.7 - "@babel/parser": 7.29.7 - "@babel/template": 7.29.7 - "@babel/traverse": 7.29.7 - "@babel/types": 7.29.7 - "@jridgewell/remapping": 2.3.5 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 @@ -4904,116 +4483,116 @@ snapshots: transitivePeerDependencies: - supports-color - "@babel/generator@7.29.7": + '@babel/generator@7.29.7': dependencies: - "@babel/parser": 7.29.7 - "@babel/types": 7.29.7 - "@jridgewell/gen-mapping": 0.3.13 - "@jridgewell/trace-mapping": 0.3.31 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - "@babel/helper-compilation-targets@7.29.7": + '@babel/helper-compilation-targets@7.29.7': dependencies: - "@babel/compat-data": 7.29.7 - "@babel/helper-validator-option": 7.29.7 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 - "@babel/helper-globals@7.29.7": {} + '@babel/helper-globals@7.29.7': {} - "@babel/helper-module-imports@7.29.7": + '@babel/helper-module-imports@7.29.7': dependencies: - "@babel/traverse": 7.29.7 - "@babel/types": 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - "@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)": + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - "@babel/core": 7.29.7 - "@babel/helper-module-imports": 7.29.7 - "@babel/helper-validator-identifier": 7.29.7 - "@babel/traverse": 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - "@babel/helper-string-parser@7.29.7": {} + '@babel/helper-string-parser@7.29.7': {} - "@babel/helper-validator-identifier@7.29.7": {} + '@babel/helper-validator-identifier@7.29.7': {} - "@babel/helper-validator-option@7.29.7": {} + '@babel/helper-validator-option@7.29.7': {} - "@babel/helpers@7.29.7": + '@babel/helpers@7.29.7': dependencies: - "@babel/template": 7.29.7 - "@babel/types": 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 - "@babel/parser@7.29.7": + '@babel/parser@7.29.7': dependencies: - "@babel/types": 7.29.7 + '@babel/types': 7.29.7 - "@babel/runtime@7.29.7": {} + '@babel/runtime@7.29.7': {} - "@babel/template@7.29.7": + '@babel/template@7.29.7': dependencies: - "@babel/code-frame": 7.29.7 - "@babel/parser": 7.29.7 - "@babel/types": 7.29.7 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 - "@babel/traverse@7.29.7": + '@babel/traverse@7.29.7': dependencies: - "@babel/code-frame": 7.29.7 - "@babel/generator": 7.29.7 - "@babel/helper-globals": 7.29.7 - "@babel/parser": 7.29.7 - "@babel/template": 7.29.7 - "@babel/types": 7.29.7 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - "@babel/types@7.29.7": + '@babel/types@7.29.7': dependencies: - "@babel/helper-string-parser": 7.29.7 - "@babel/helper-validator-identifier": 7.29.7 + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 - "@bramus/specificity@2.4.2": + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 - "@csstools/color-helpers@6.0.2": {} + '@csstools/color-helpers@6.0.2': {} - "@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - "@csstools/css-parser-algorithms": 4.0.0(@csstools/css-tokenizer@4.0.0) - "@csstools/css-tokenizer": 4.0.0 + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - "@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - "@csstools/color-helpers": 6.0.2 - "@csstools/css-calc": 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - "@csstools/css-parser-algorithms": 4.0.0(@csstools/css-tokenizer@4.0.0) - "@csstools/css-tokenizer": 4.0.0 + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - "@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)": + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': dependencies: - "@csstools/css-tokenizer": 4.0.0 + '@csstools/css-tokenizer': 4.0.0 - "@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)": + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 - "@csstools/css-tokenizer@4.0.0": {} + '@csstools/css-tokenizer@4.0.0': {} - "@electron-forge/cli@7.11.2(encoding@0.1.13)": + '@electron-forge/cli@7.11.2(encoding@0.1.13)': dependencies: - "@electron-forge/core": 7.11.2(encoding@0.1.13) - "@electron-forge/core-utils": 7.11.2 - "@electron-forge/shared-types": 7.11.2 - "@electron/get": 3.1.0 - "@inquirer/prompts": 6.0.1 - "@listr2/prompt-adapter-inquirer": 2.0.22(@inquirer/prompts@6.0.1) + '@electron-forge/core': 7.11.2(encoding@0.1.13) + '@electron-forge/core-utils': 7.11.2 + '@electron-forge/shared-types': 7.11.2 + '@electron/get': 3.1.0 + '@inquirer/prompts': 6.0.1 + '@listr2/prompt-adapter-inquirer': 2.0.22(@inquirer/prompts@6.0.1) chalk: 4.1.2 commander: 11.1.0 debug: 4.4.3(supports-color@10.2.2) @@ -5022,10 +4601,10 @@ snapshots: log-symbols: 4.1.0 semver: 7.8.4 transitivePeerDependencies: - - "@minify-html/node" - - "@swc/core" - - "@swc/css" - - "@swc/html" + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' - bluebird - clean-css - cssnano @@ -5039,11 +4618,11 @@ snapshots: - uglify-js - webpack-cli - "@electron-forge/core-utils@7.11.2": + '@electron-forge/core-utils@7.11.2': dependencies: - "@electron-forge/shared-types": 7.11.2 - "@electron/rebuild": 3.7.2 - "@malept/cross-spawn-promise": 2.0.0 + '@electron-forge/shared-types': 7.11.2 + '@electron/rebuild': 3.7.2 + '@malept/cross-spawn-promise': 2.0.0 chalk: 4.1.2 debug: 4.4.3(supports-color@10.2.2) find-up: 5.0.0 @@ -5055,24 +4634,24 @@ snapshots: - bluebird - supports-color - "@electron-forge/core@7.11.2(encoding@0.1.13)": - dependencies: - "@electron-forge/core-utils": 7.11.2 - "@electron-forge/maker-base": 7.11.2 - "@electron-forge/plugin-base": 7.11.2 - "@electron-forge/publisher-base": 7.11.2 - "@electron-forge/shared-types": 7.11.2 - "@electron-forge/template-base": 7.11.2 - "@electron-forge/template-vite": 7.11.2 - "@electron-forge/template-vite-typescript": 7.11.2 - "@electron-forge/template-webpack": 7.11.2 - "@electron-forge/template-webpack-typescript": 7.11.2 - "@electron-forge/tracer": 7.11.2 - "@electron/get": 3.1.0 - "@electron/packager": 18.4.4 - "@electron/rebuild": 3.7.2 - "@malept/cross-spawn-promise": 2.0.0 - "@vscode/sudo-prompt": 9.3.2 + '@electron-forge/core@7.11.2(encoding@0.1.13)': + dependencies: + '@electron-forge/core-utils': 7.11.2 + '@electron-forge/maker-base': 7.11.2 + '@electron-forge/plugin-base': 7.11.2 + '@electron-forge/publisher-base': 7.11.2 + '@electron-forge/shared-types': 7.11.2 + '@electron-forge/template-base': 7.11.2 + '@electron-forge/template-vite': 7.11.2 + '@electron-forge/template-vite-typescript': 7.11.2 + '@electron-forge/template-webpack': 7.11.2 + '@electron-forge/template-webpack-typescript': 7.11.2 + '@electron-forge/tracer': 7.11.2 + '@electron/get': 3.1.0 + '@electron/packager': 18.4.4 + '@electron/rebuild': 3.7.2 + '@malept/cross-spawn-promise': 2.0.0 + '@vscode/sudo-prompt': 9.3.2 chalk: 4.1.2 debug: 4.4.3(supports-color@10.2.2) eta: 3.5.0 @@ -5092,10 +4671,10 @@ snapshots: source-map-support: 0.5.21 username: 5.1.0 transitivePeerDependencies: - - "@minify-html/node" - - "@swc/core" - - "@swc/css" - - "@swc/html" + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' - bluebird - clean-css - cssnano @@ -5109,50 +4688,39 @@ snapshots: - uglify-js - webpack-cli - "@electron-forge/maker-base@7.11.2": + '@electron-forge/maker-base@7.11.2': dependencies: - "@electron-forge/shared-types": 7.11.2 + '@electron-forge/shared-types': 7.11.2 fs-extra: 10.1.0 which: 2.0.2 transitivePeerDependencies: - bluebird - supports-color - "@electron-forge/maker-deb@7.11.2": + '@electron-forge/maker-deb@7.11.2': dependencies: - "@electron-forge/maker-base": 7.11.2 - "@electron-forge/shared-types": 7.11.2 + '@electron-forge/maker-base': 7.11.2 + '@electron-forge/shared-types': 7.11.2 optionalDependencies: electron-installer-debian: 3.2.0 transitivePeerDependencies: - bluebird - supports-color - "@electron-forge/maker-rpm@7.11.2": + '@electron-forge/maker-rpm@7.11.2': dependencies: - "@electron-forge/maker-base": 7.11.2 - "@electron-forge/shared-types": 7.11.2 + '@electron-forge/maker-base': 7.11.2 + '@electron-forge/shared-types': 7.11.2 optionalDependencies: electron-installer-redhat: 3.4.0 transitivePeerDependencies: - bluebird - supports-color - "@electron-forge/maker-squirrel@7.11.2": + '@electron-forge/maker-zip@7.11.2': dependencies: - "@electron-forge/maker-base": 7.11.2 - "@electron-forge/shared-types": 7.11.2 - fs-extra: 10.1.0 - optionalDependencies: - electron-winstaller: 5.4.0 - transitivePeerDependencies: - - bluebird - - supports-color - - "@electron-forge/maker-zip@7.11.2": - dependencies: - "@electron-forge/maker-base": 7.11.2 - "@electron-forge/shared-types": 7.11.2 + '@electron-forge/maker-base': 7.11.2 + '@electron-forge/shared-types': 7.11.2 cross-zip: 4.0.1 fs-extra: 10.1.0 got: 11.8.6 @@ -5160,17 +4728,17 @@ snapshots: - bluebird - supports-color - "@electron-forge/plugin-base@7.11.2": + '@electron-forge/plugin-base@7.11.2': dependencies: - "@electron-forge/shared-types": 7.11.2 + '@electron-forge/shared-types': 7.11.2 transitivePeerDependencies: - bluebird - supports-color - "@electron-forge/plugin-vite@7.11.2": + '@electron-forge/plugin-vite@7.11.2': dependencies: - "@electron-forge/plugin-base": 7.11.2 - "@electron-forge/shared-types": 7.11.2 + '@electron-forge/plugin-base': 7.11.2 + '@electron-forge/shared-types': 7.11.2 chalk: 4.1.2 debug: 4.4.3(supports-color@10.2.2) fs-extra: 10.1.0 @@ -5179,22 +4747,22 @@ snapshots: - bluebird - supports-color - "@electron-forge/publisher-base@7.11.2": + '@electron-forge/publisher-base@7.11.2': dependencies: - "@electron-forge/shared-types": 7.11.2 + '@electron-forge/shared-types': 7.11.2 transitivePeerDependencies: - bluebird - supports-color - "@electron-forge/publisher-github@7.11.2": + '@electron-forge/publisher-github@7.11.2': dependencies: - "@electron-forge/publisher-base": 7.11.2 - "@electron-forge/shared-types": 7.11.2 - "@octokit/core": 5.2.2 - "@octokit/plugin-retry": 6.1.0(@octokit/core@5.2.2) - "@octokit/request-error": 5.1.1 - "@octokit/rest": 20.1.2 - "@octokit/types": 6.41.0 + '@electron-forge/publisher-base': 7.11.2 + '@electron-forge/shared-types': 7.11.2 + '@octokit/core': 5.2.2 + '@octokit/plugin-retry': 6.1.0(@octokit/core@5.2.2) + '@octokit/request-error': 5.1.1 + '@octokit/rest': 20.1.2 + '@octokit/types': 6.41.0 chalk: 4.1.2 debug: 4.4.3(supports-color@10.2.2) fs-extra: 10.1.0 @@ -5204,21 +4772,21 @@ snapshots: - bluebird - supports-color - "@electron-forge/shared-types@7.11.2": + '@electron-forge/shared-types@7.11.2': dependencies: - "@electron-forge/tracer": 7.11.2 - "@electron/packager": 18.4.4 - "@electron/rebuild": 3.7.2 + '@electron-forge/tracer': 7.11.2 + '@electron/packager': 18.4.4 + '@electron/rebuild': 3.7.2 listr2: 7.0.2 transitivePeerDependencies: - bluebird - supports-color - "@electron-forge/template-base@7.11.2": + '@electron-forge/template-base@7.11.2': dependencies: - "@electron-forge/core-utils": 7.11.2 - "@electron-forge/shared-types": 7.11.2 - "@malept/cross-spawn-promise": 2.0.0 + '@electron-forge/core-utils': 7.11.2 + '@electron-forge/shared-types': 7.11.2 + '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3(supports-color@10.2.2) fs-extra: 10.1.0 semver: 7.8.4 @@ -5227,36 +4795,36 @@ snapshots: - bluebird - supports-color - "@electron-forge/template-vite-typescript@7.11.2": + '@electron-forge/template-vite-typescript@7.11.2': dependencies: - "@electron-forge/shared-types": 7.11.2 - "@electron-forge/template-base": 7.11.2 + '@electron-forge/shared-types': 7.11.2 + '@electron-forge/template-base': 7.11.2 fs-extra: 10.1.0 transitivePeerDependencies: - bluebird - supports-color - "@electron-forge/template-vite@7.11.2": + '@electron-forge/template-vite@7.11.2': dependencies: - "@electron-forge/shared-types": 7.11.2 - "@electron-forge/template-base": 7.11.2 + '@electron-forge/shared-types': 7.11.2 + '@electron-forge/template-base': 7.11.2 fs-extra: 10.1.0 transitivePeerDependencies: - bluebird - supports-color - "@electron-forge/template-webpack-typescript@7.11.2": + '@electron-forge/template-webpack-typescript@7.11.2': dependencies: - "@electron-forge/shared-types": 7.11.2 - "@electron-forge/template-base": 7.11.2 + '@electron-forge/shared-types': 7.11.2 + '@electron-forge/template-base': 7.11.2 fs-extra: 10.1.0 typescript: 5.4.5 webpack: 5.107.2 transitivePeerDependencies: - - "@minify-html/node" - - "@swc/core" - - "@swc/css" - - "@swc/html" + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' - bluebird - clean-css - cssnano @@ -5269,26 +4837,32 @@ snapshots: - uglify-js - webpack-cli - "@electron-forge/template-webpack@7.11.2": + '@electron-forge/template-webpack@7.11.2': dependencies: - "@electron-forge/shared-types": 7.11.2 - "@electron-forge/template-base": 7.11.2 + '@electron-forge/shared-types': 7.11.2 + '@electron-forge/template-base': 7.11.2 fs-extra: 10.1.0 transitivePeerDependencies: - bluebird - supports-color - "@electron-forge/tracer@7.11.2": + '@electron-forge/tracer@7.11.2': dependencies: chrome-trace-event: 1.0.4 - "@electron/asar@3.4.1": + '@electron/asar@3.4.1': dependencies: commander: 5.1.0 glob: 7.2.3 minimatch: 3.1.5 - "@electron/get@2.0.3": + '@electron/fuses@1.8.0': + dependencies: + chalk: 4.1.2 + fs-extra: 9.1.0 + minimist: 1.2.8 + + '@electron/get@2.0.3': dependencies: debug: 4.4.3(supports-color@10.2.2) env-paths: 2.2.1 @@ -5302,7 +4876,7 @@ snapshots: transitivePeerDependencies: - supports-color - "@electron/get@3.1.0": + '@electron/get@3.1.0': dependencies: debug: 4.4.3(supports-color@10.2.2) env-paths: 2.2.1 @@ -5316,7 +4890,7 @@ snapshots: transitivePeerDependencies: - supports-color - "@electron/node-gyp@https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2": + '@electron/node-gyp@https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2': dependencies: env-paths: 2.2.1 exponential-backoff: 3.1.3 @@ -5332,7 +4906,7 @@ snapshots: - bluebird - supports-color - "@electron/notarize@2.5.0": + '@electron/notarize@2.5.0': dependencies: debug: 4.4.3(supports-color@10.2.2) fs-extra: 9.1.0 @@ -5340,7 +4914,7 @@ snapshots: transitivePeerDependencies: - supports-color - "@electron/osx-sign@1.3.3": + '@electron/osx-sign@1.3.3': dependencies: compare-version: 0.1.2 debug: 4.4.3(supports-color@10.2.2) @@ -5351,15 +4925,15 @@ snapshots: transitivePeerDependencies: - supports-color - "@electron/packager@18.4.4": + '@electron/packager@18.4.4': dependencies: - "@electron/asar": 3.4.1 - "@electron/get": 3.1.0 - "@electron/notarize": 2.5.0 - "@electron/osx-sign": 1.3.3 - "@electron/universal": 2.0.3 - "@electron/windows-sign": 1.2.2 - "@malept/cross-spawn-promise": 2.0.0 + '@electron/asar': 3.4.1 + '@electron/get': 3.1.0 + '@electron/notarize': 2.5.0 + '@electron/osx-sign': 1.3.3 + '@electron/universal': 2.0.3 + '@electron/windows-sign': 1.2.2 + '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3(supports-color@10.2.2) extract-zip: 2.0.1 filenamify: 4.3.0 @@ -5377,10 +4951,10 @@ snapshots: transitivePeerDependencies: - supports-color - "@electron/rebuild@3.7.2": + '@electron/rebuild@3.7.2': dependencies: - "@electron/node-gyp": https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2 - "@malept/cross-spawn-promise": 2.0.0 + '@electron/node-gyp': https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2 + '@malept/cross-spawn-promise': 2.0.0 chalk: 4.1.2 debug: 4.4.3(supports-color@10.2.2) detect-libc: 2.1.2 @@ -5397,10 +4971,21 @@ snapshots: - bluebird - supports-color - "@electron/universal@2.0.3": + '@electron/rebuild@4.0.4': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + node-abi: 4.31.0 + node-api-version: 0.2.1 + node-gyp: 12.4.0 + read-binary-file-arch: 1.0.6 + transitivePeerDependencies: + - supports-color + + '@electron/universal@2.0.3': dependencies: - "@electron/asar": 3.4.1 - "@malept/cross-spawn-promise": 2.0.0 + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3(supports-color@10.2.2) dir-compare: 4.2.0 fs-extra: 11.3.5 @@ -5409,7 +4994,7 @@ snapshots: transitivePeerDependencies: - supports-color - "@electron/windows-sign@1.2.2": + '@electron/windows-sign@1.2.2': dependencies: cross-dirname: 0.1.0 debug: 4.4.3(supports-color@10.2.2) @@ -5419,63 +5004,65 @@ snapshots: transitivePeerDependencies: - supports-color - "@emnapi/core@1.10.0": + '@emnapi/core@1.10.0': dependencies: - "@emnapi/wasi-threads": 1.2.1 + '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true - "@emnapi/runtime@1.10.0": + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true - "@emnapi/wasi-threads@1.2.1": + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true - "@exodus/bytes@1.15.1": {} + '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': + optionalDependencies: + '@noble/hashes': 2.2.0 - "@floating-ui/core@1.7.5": + '@floating-ui/core@1.7.5': dependencies: - "@floating-ui/utils": 0.2.11 + '@floating-ui/utils': 0.2.11 - "@floating-ui/dom@1.7.6": + '@floating-ui/dom@1.7.6': dependencies: - "@floating-ui/core": 1.7.5 - "@floating-ui/utils": 0.2.11 + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 - "@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@floating-ui/dom": 1.7.6 + '@floating-ui/dom': 1.7.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - "@floating-ui/utils@0.2.11": {} + '@floating-ui/utils@0.2.11': {} - "@gar/promisify@1.1.3": {} + '@gar/promisify@1.1.3': {} - "@inquirer/checkbox@3.0.1": + '@inquirer/checkbox@3.0.1': dependencies: - "@inquirer/core": 9.2.1 - "@inquirer/figures": 1.0.15 - "@inquirer/type": 2.0.0 + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 2.0.0 ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.3 - "@inquirer/confirm@4.0.1": + '@inquirer/confirm@4.0.1': dependencies: - "@inquirer/core": 9.2.1 - "@inquirer/type": 2.0.0 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 - "@inquirer/core@9.2.1": + '@inquirer/core@9.2.1': dependencies: - "@inquirer/figures": 1.0.15 - "@inquirer/type": 2.0.0 - "@types/mute-stream": 0.0.4 - "@types/node": 22.19.20 - "@types/wrap-ansi": 3.0.0 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 2.0.0 + '@types/mute-stream': 0.0.4 + '@types/node': 22.19.20 + '@types/wrap-ansi': 3.0.0 ansi-escapes: 4.3.2 cli-width: 4.1.0 mute-stream: 1.0.0 @@ -5484,985 +5071,1030 @@ snapshots: wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 - "@inquirer/editor@3.0.1": + '@inquirer/editor@3.0.1': dependencies: - "@inquirer/core": 9.2.1 - "@inquirer/type": 2.0.0 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 external-editor: 3.1.0 - "@inquirer/expand@3.0.1": + '@inquirer/expand@3.0.1': dependencies: - "@inquirer/core": 9.2.1 - "@inquirer/type": 2.0.0 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 yoctocolors-cjs: 2.1.3 - "@inquirer/figures@1.0.15": {} + '@inquirer/figures@1.0.15': {} - "@inquirer/input@3.0.1": + '@inquirer/input@3.0.1': dependencies: - "@inquirer/core": 9.2.1 - "@inquirer/type": 2.0.0 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 - "@inquirer/number@2.0.1": + '@inquirer/number@2.0.1': dependencies: - "@inquirer/core": 9.2.1 - "@inquirer/type": 2.0.0 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 - "@inquirer/password@3.0.1": + '@inquirer/password@3.0.1': dependencies: - "@inquirer/core": 9.2.1 - "@inquirer/type": 2.0.0 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 ansi-escapes: 4.3.2 - "@inquirer/prompts@6.0.1": + '@inquirer/prompts@6.0.1': dependencies: - "@inquirer/checkbox": 3.0.1 - "@inquirer/confirm": 4.0.1 - "@inquirer/editor": 3.0.1 - "@inquirer/expand": 3.0.1 - "@inquirer/input": 3.0.1 - "@inquirer/number": 2.0.1 - "@inquirer/password": 3.0.1 - "@inquirer/rawlist": 3.0.1 - "@inquirer/search": 2.0.1 - "@inquirer/select": 3.0.1 + '@inquirer/checkbox': 3.0.1 + '@inquirer/confirm': 4.0.1 + '@inquirer/editor': 3.0.1 + '@inquirer/expand': 3.0.1 + '@inquirer/input': 3.0.1 + '@inquirer/number': 2.0.1 + '@inquirer/password': 3.0.1 + '@inquirer/rawlist': 3.0.1 + '@inquirer/search': 2.0.1 + '@inquirer/select': 3.0.1 - "@inquirer/rawlist@3.0.1": + '@inquirer/rawlist@3.0.1': dependencies: - "@inquirer/core": 9.2.1 - "@inquirer/type": 2.0.0 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 yoctocolors-cjs: 2.1.3 - "@inquirer/search@2.0.1": + '@inquirer/search@2.0.1': dependencies: - "@inquirer/core": 9.2.1 - "@inquirer/figures": 1.0.15 - "@inquirer/type": 2.0.0 + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 2.0.0 yoctocolors-cjs: 2.1.3 - "@inquirer/select@3.0.1": + '@inquirer/select@3.0.1': dependencies: - "@inquirer/core": 9.2.1 - "@inquirer/figures": 1.0.15 - "@inquirer/type": 2.0.0 + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 2.0.0 ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.3 - "@inquirer/type@1.5.5": + '@inquirer/type@1.5.5': dependencies: mute-stream: 1.0.0 - "@inquirer/type@2.0.0": + '@inquirer/type@2.0.0': dependencies: mute-stream: 1.0.0 - "@jridgewell/gen-mapping@0.3.13": + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@jridgewell/gen-mapping@0.3.13': dependencies: - "@jridgewell/sourcemap-codec": 1.5.5 - "@jridgewell/trace-mapping": 0.3.31 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 - "@jridgewell/remapping@2.3.5": + '@jridgewell/remapping@2.3.5': dependencies: - "@jridgewell/gen-mapping": 0.3.13 - "@jridgewell/trace-mapping": 0.3.31 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - "@jridgewell/resolve-uri@3.1.2": {} + '@jridgewell/resolve-uri@3.1.2': {} - "@jridgewell/source-map@0.3.11": + '@jridgewell/source-map@0.3.11': dependencies: - "@jridgewell/gen-mapping": 0.3.13 - "@jridgewell/trace-mapping": 0.3.31 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - "@jridgewell/sourcemap-codec@1.5.5": {} + '@jridgewell/sourcemap-codec@1.5.5': {} - "@jridgewell/trace-mapping@0.3.31": + '@jridgewell/trace-mapping@0.3.31': dependencies: - "@jridgewell/resolve-uri": 3.1.2 - "@jridgewell/sourcemap-codec": 1.5.5 + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 - "@listr2/prompt-adapter-inquirer@2.0.22(@inquirer/prompts@6.0.1)": + '@listr2/prompt-adapter-inquirer@2.0.22(@inquirer/prompts@6.0.1)': dependencies: - "@inquirer/prompts": 6.0.1 - "@inquirer/type": 1.5.5 + '@inquirer/prompts': 6.0.1 + '@inquirer/type': 1.5.5 - "@malept/cross-spawn-promise@1.1.1": + '@malept/cross-spawn-promise@1.1.1': dependencies: cross-spawn: 7.0.6 optional: true - "@malept/cross-spawn-promise@2.0.0": + '@malept/cross-spawn-promise@2.0.0': dependencies: cross-spawn: 7.0.6 - "@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)": + '@malept/flatpak-bundler@0.4.0': + dependencies: + debug: 4.4.3(supports-color@10.2.2) + fs-extra: 9.1.0 + lodash: 4.18.1 + tmp-promise: 3.0.3 + transitivePeerDependencies: + - supports-color + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - "@emnapi/core": 1.10.0 - "@emnapi/runtime": 1.10.0 - "@tybys/wasm-util": 0.10.2 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 optional: true - "@nodelib/fs.scandir@2.1.5": + '@noble/hashes@1.4.0': {} + + '@noble/hashes@2.2.0': {} + + '@nodelib/fs.scandir@2.1.5': dependencies: - "@nodelib/fs.stat": 2.0.5 + '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 - "@nodelib/fs.stat@2.0.5": {} + '@nodelib/fs.stat@2.0.5': {} - "@nodelib/fs.walk@1.2.8": + '@nodelib/fs.walk@1.2.8': dependencies: - "@nodelib/fs.scandir": 2.1.5 + '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - "@npmcli/fs@2.1.2": + '@npmcli/fs@2.1.2': dependencies: - "@gar/promisify": 1.1.3 + '@gar/promisify': 1.1.3 semver: 7.8.4 - "@npmcli/move-file@2.0.1": + '@npmcli/move-file@2.0.1': dependencies: mkdirp: 1.0.4 rimraf: 3.0.2 - "@octokit/auth-token@4.0.0": {} + '@octokit/auth-token@4.0.0': {} - "@octokit/core@5.2.2": + '@octokit/core@5.2.2': dependencies: - "@octokit/auth-token": 4.0.0 - "@octokit/graphql": 7.1.1 - "@octokit/request": 8.4.1 - "@octokit/request-error": 5.1.1 - "@octokit/types": 13.10.0 + '@octokit/auth-token': 4.0.0 + '@octokit/graphql': 7.1.1 + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 before-after-hook: 2.2.3 universal-user-agent: 6.0.1 - "@octokit/endpoint@9.0.6": + '@octokit/endpoint@9.0.6': dependencies: - "@octokit/types": 13.10.0 + '@octokit/types': 13.10.0 universal-user-agent: 6.0.1 - "@octokit/graphql@7.1.1": + '@octokit/graphql@7.1.1': dependencies: - "@octokit/request": 8.4.1 - "@octokit/types": 13.10.0 + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 universal-user-agent: 6.0.1 - "@octokit/openapi-types@12.11.0": {} + '@octokit/openapi-types@12.11.0': {} - "@octokit/openapi-types@24.2.0": {} + '@octokit/openapi-types@24.2.0': {} - "@octokit/plugin-paginate-rest@11.4.4-cjs.2(@octokit/core@5.2.2)": + '@octokit/plugin-paginate-rest@11.4.4-cjs.2(@octokit/core@5.2.2)': dependencies: - "@octokit/core": 5.2.2 - "@octokit/types": 13.10.0 + '@octokit/core': 5.2.2 + '@octokit/types': 13.10.0 - "@octokit/plugin-request-log@4.0.1(@octokit/core@5.2.2)": + '@octokit/plugin-request-log@4.0.1(@octokit/core@5.2.2)': dependencies: - "@octokit/core": 5.2.2 + '@octokit/core': 5.2.2 - "@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1(@octokit/core@5.2.2)": + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1(@octokit/core@5.2.2)': dependencies: - "@octokit/core": 5.2.2 - "@octokit/types": 13.10.0 + '@octokit/core': 5.2.2 + '@octokit/types': 13.10.0 - "@octokit/plugin-retry@6.1.0(@octokit/core@5.2.2)": + '@octokit/plugin-retry@6.1.0(@octokit/core@5.2.2)': dependencies: - "@octokit/core": 5.2.2 - "@octokit/request-error": 5.1.1 - "@octokit/types": 13.10.0 + '@octokit/core': 5.2.2 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 bottleneck: 2.19.5 - "@octokit/request-error@5.1.1": + '@octokit/request-error@5.1.1': dependencies: - "@octokit/types": 13.10.0 + '@octokit/types': 13.10.0 deprecation: 2.3.1 once: 1.4.0 - "@octokit/request@8.4.1": + '@octokit/request@8.4.1': dependencies: - "@octokit/endpoint": 9.0.6 - "@octokit/request-error": 5.1.1 - "@octokit/types": 13.10.0 + '@octokit/endpoint': 9.0.6 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 universal-user-agent: 6.0.1 - "@octokit/rest@20.1.2": + '@octokit/rest@20.1.2': dependencies: - "@octokit/core": 5.2.2 - "@octokit/plugin-paginate-rest": 11.4.4-cjs.2(@octokit/core@5.2.2) - "@octokit/plugin-request-log": 4.0.1(@octokit/core@5.2.2) - "@octokit/plugin-rest-endpoint-methods": 13.3.2-cjs.1(@octokit/core@5.2.2) + '@octokit/core': 5.2.2 + '@octokit/plugin-paginate-rest': 11.4.4-cjs.2(@octokit/core@5.2.2) + '@octokit/plugin-request-log': 4.0.1(@octokit/core@5.2.2) + '@octokit/plugin-rest-endpoint-methods': 13.3.2-cjs.1(@octokit/core@5.2.2) - "@octokit/types@13.10.0": + '@octokit/types@13.10.0': dependencies: - "@octokit/openapi-types": 24.2.0 + '@octokit/openapi-types': 24.2.0 - "@octokit/types@6.41.0": + '@octokit/types@6.41.0': dependencies: - "@octokit/openapi-types": 12.11.0 + '@octokit/openapi-types': 12.11.0 - "@oxc-project/types@0.133.0": {} + '@oxc-project/types@0.133.0': {} + + '@peculiar/asn1-schema@2.8.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/json-schema@1.1.12': + dependencies: + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 - "@playwright/test@1.60.0": + '@peculiar/webcrypto@1.7.1': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + tslib: 2.8.1 + webcrypto-core: 1.9.2 + + '@playwright/test@1.60.0': dependencies: playwright: 1.60.0 - "@radix-ui/number@1.1.2": {} + '@posthog/core@1.37.1': + dependencies: + '@posthog/types': 1.391.0 + + '@posthog/types@1.391.0': {} + + '@radix-ui/number@1.1.2': {} - "@radix-ui/primitive@1.1.4": {} + '@radix-ui/primitive@1.1.4': {} - "@radix-ui/react-accessible-icon@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-accessible-icon@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/react-visually-hidden": 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-accordion@1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-collapsible": 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-collection": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-accordion@1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-alert-dialog@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-alert-dialog@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-dialog": 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-slot": 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-arrow@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-arrow@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-aspect-ratio@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-aspect-ratio@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-avatar@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-avatar@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-callback-ref": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-is-hydrated": 0.1.1(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-checkbox@1.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-previous": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-size": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-checkbox@1.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-collapsible@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collapsible@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-collection@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-collection@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-slot": 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-context-menu@2.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-context-menu@2.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-menu": 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 - - "@radix-ui/react-dialog@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-dismissable-layer": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-focus-guards": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-focus-scope": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-portal": 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-slot": 1.2.5(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + + '@radix-ui/react-dialog@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-dismissable-layer@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-dismissable-layer@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-callback-ref": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-escape-keydown": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-dropdown-menu@2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-menu": 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-dropdown-menu@2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-focus-scope@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-focus-scope@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-callback-ref": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-form@0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-form@0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-label": 2.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-label': 2.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-hover-card@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-dismissable-layer": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-popper": 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-portal": 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-hover-card@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-label@2.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-label@2.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-menu@2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-collection": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-dismissable-layer": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-focus-guards": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-focus-scope": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-popper": 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-portal": 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-roving-focus": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-slot": 1.2.5(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-callback-ref": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-menu@2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-menubar@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-collection": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-menu": 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-roving-focus": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-menubar@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-navigation-menu@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-collection": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-dismissable-layer": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-callback-ref": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-previous": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-visually-hidden": 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-navigation-menu@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-one-time-password-field@0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/number": 1.1.2 - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-collection": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-roving-focus": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-effect-event": 0.0.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-is-hydrated": 0.1.1(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-one-time-password-field@0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-password-toggle-field@0.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-effect-event": 0.0.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-is-hydrated": 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-password-toggle-field@0.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-popover@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-dismissable-layer": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-focus-guards": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-focus-scope": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-popper": 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-portal": 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-slot": 1.2.5(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popover@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-popper@1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@floating-ui/react-dom": 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-arrow": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-callback-ref": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-rect": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-size": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/rect": 1.1.2 + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.2 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-portal@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-portal@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-primitive@2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-primitive@2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/react-slot": 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-progress@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-progress@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-radio-group@1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-roving-focus": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-previous": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-size": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-radio-group@1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-roving-focus@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-collection": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-callback-ref": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-roving-focus@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-scroll-area@1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/number": 1.1.2 - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-callback-ref": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-scroll-area@1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-select@2.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/number": 1.1.2 - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-collection": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-dismissable-layer": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-focus-guards": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-focus-scope": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-popper": 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-portal": 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-slot": 1.2.5(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-callback-ref": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-previous": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-visually-hidden": 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-select@2.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-separator@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-separator@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-slider@1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/number": 1.1.2 - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-collection": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-previous": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-size": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slider@1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-slot@1.2.5(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-slot@1.2.5(@types/react@19.2.17)(react@19.2.7)': dependencies: - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-switch@1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-switch@1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-previous": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-size": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-tabs@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-roving-focus": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-tabs@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-toast@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-collection": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-dismissable-layer": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-portal": 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-callback-ref": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-visually-hidden": 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toast@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-toggle-group@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-roving-focus": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-toggle": 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toggle-group@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-toggle@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-toggle@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-toolbar@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-roving-focus": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-separator": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-toggle-group": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toolbar@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) - - "@radix-ui/react-tooltip@1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": - dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-dismissable-layer": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-id": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-popper": 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-portal": 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-slot": 1.2.5(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-visually-hidden": 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-tooltip@1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': dependencies: - "@radix-ui/react-use-effect-event": 0.0.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': dependencies: - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-use-escape-keydown@1.1.2(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: - "@radix-ui/react-use-callback-ref": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-use-previous@1.1.2(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-use-previous@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: - "@radix-ui/rect": 1.1.2 + '@radix-ui/rect': 1.1.2 react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)": + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@radix-ui/react-visually-hidden@1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@radix-ui/react-visually-hidden@1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@radix-ui/rect@1.1.2": {} + '@radix-ui/rect@1.1.2': {} - "@redocly/ajv@8.11.2": + '@redocly/ajv@8.11.2': dependencies: fast-deep-equal: 3.1.3 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 uri-js-replace: 1.0.1 - "@redocly/config@0.22.0": {} + '@redocly/config@0.22.0': {} - "@redocly/openapi-core@1.34.15(supports-color@10.2.2)": + '@redocly/openapi-core@1.34.15(supports-color@10.2.2)': dependencies: - "@redocly/ajv": 8.11.2 - "@redocly/config": 0.22.0 + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 colorette: 1.4.0 https-proxy-agent: 7.0.6(supports-color@10.2.2) js-levenshtein: 1.1.6 @@ -6473,68 +6105,68 @@ snapshots: transitivePeerDependencies: - supports-color - "@rolldown/binding-android-arm64@1.0.3": + '@rolldown/binding-android-arm64@1.0.3': optional: true - "@rolldown/binding-darwin-arm64@1.0.3": + '@rolldown/binding-darwin-arm64@1.0.3': optional: true - "@rolldown/binding-darwin-x64@1.0.3": + '@rolldown/binding-darwin-x64@1.0.3': optional: true - "@rolldown/binding-freebsd-x64@1.0.3": + '@rolldown/binding-freebsd-x64@1.0.3': optional: true - "@rolldown/binding-linux-arm-gnueabihf@1.0.3": + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': optional: true - "@rolldown/binding-linux-arm64-gnu@1.0.3": + '@rolldown/binding-linux-arm64-gnu@1.0.3': optional: true - "@rolldown/binding-linux-arm64-musl@1.0.3": + '@rolldown/binding-linux-arm64-musl@1.0.3': optional: true - "@rolldown/binding-linux-ppc64-gnu@1.0.3": + '@rolldown/binding-linux-ppc64-gnu@1.0.3': optional: true - "@rolldown/binding-linux-s390x-gnu@1.0.3": + '@rolldown/binding-linux-s390x-gnu@1.0.3': optional: true - "@rolldown/binding-linux-x64-gnu@1.0.3": + '@rolldown/binding-linux-x64-gnu@1.0.3': optional: true - "@rolldown/binding-linux-x64-musl@1.0.3": + '@rolldown/binding-linux-x64-musl@1.0.3': optional: true - "@rolldown/binding-openharmony-arm64@1.0.3": + '@rolldown/binding-openharmony-arm64@1.0.3': optional: true - "@rolldown/binding-wasm32-wasi@1.0.3": + '@rolldown/binding-wasm32-wasi@1.0.3': dependencies: - "@emnapi/core": 1.10.0 - "@emnapi/runtime": 1.10.0 - "@napi-rs/wasm-runtime": 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - "@rolldown/binding-win32-arm64-msvc@1.0.3": + '@rolldown/binding-win32-arm64-msvc@1.0.3': optional: true - "@rolldown/binding-win32-x64-msvc@1.0.3": + '@rolldown/binding-win32-x64-msvc@1.0.3': optional: true - "@rolldown/pluginutils@1.0.1": {} + '@rolldown/pluginutils@1.0.1': {} - "@sindresorhus/is@4.6.0": {} + '@sindresorhus/is@4.6.0': {} - "@standard-schema/spec@1.1.0": {} + '@standard-schema/spec@1.1.0': {} - "@szmarczak/http-timer@4.0.6": + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 - "@tailwindcss/node@4.3.0": + '@tailwindcss/node@4.3.0': dependencies: - "@jridgewell/remapping": 2.3.5 + '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.23.0 jiti: 2.7.0 lightningcss: 1.32.0 @@ -6542,102 +6174,102 @@ snapshots: source-map-js: 1.2.1 tailwindcss: 4.3.0 - "@tailwindcss/oxide-android-arm64@4.3.0": + '@tailwindcss/oxide-android-arm64@4.3.0': optional: true - "@tailwindcss/oxide-darwin-arm64@4.3.0": + '@tailwindcss/oxide-darwin-arm64@4.3.0': optional: true - "@tailwindcss/oxide-darwin-x64@4.3.0": + '@tailwindcss/oxide-darwin-x64@4.3.0': optional: true - "@tailwindcss/oxide-freebsd-x64@4.3.0": + '@tailwindcss/oxide-freebsd-x64@4.3.0': optional: true - "@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0": + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': optional: true - "@tailwindcss/oxide-linux-arm64-gnu@4.3.0": + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': optional: true - "@tailwindcss/oxide-linux-arm64-musl@4.3.0": + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': optional: true - "@tailwindcss/oxide-linux-x64-gnu@4.3.0": + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': optional: true - "@tailwindcss/oxide-linux-x64-musl@4.3.0": + '@tailwindcss/oxide-linux-x64-musl@4.3.0': optional: true - "@tailwindcss/oxide-wasm32-wasi@4.3.0": + '@tailwindcss/oxide-wasm32-wasi@4.3.0': optional: true - "@tailwindcss/oxide-win32-arm64-msvc@4.3.0": + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': optional: true - "@tailwindcss/oxide-win32-x64-msvc@4.3.0": + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': optional: true - "@tailwindcss/oxide@4.3.0": + '@tailwindcss/oxide@4.3.0': optionalDependencies: - "@tailwindcss/oxide-android-arm64": 4.3.0 - "@tailwindcss/oxide-darwin-arm64": 4.3.0 - "@tailwindcss/oxide-darwin-x64": 4.3.0 - "@tailwindcss/oxide-freebsd-x64": 4.3.0 - "@tailwindcss/oxide-linux-arm-gnueabihf": 4.3.0 - "@tailwindcss/oxide-linux-arm64-gnu": 4.3.0 - "@tailwindcss/oxide-linux-arm64-musl": 4.3.0 - "@tailwindcss/oxide-linux-x64-gnu": 4.3.0 - "@tailwindcss/oxide-linux-x64-musl": 4.3.0 - "@tailwindcss/oxide-wasm32-wasi": 4.3.0 - "@tailwindcss/oxide-win32-arm64-msvc": 4.3.0 - "@tailwindcss/oxide-win32-x64-msvc": 4.3.0 - - "@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0))": - dependencies: - "@tailwindcss/node": 4.3.0 - "@tailwindcss/oxide": 4.3.0 + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0) - "@tanstack/history@1.162.0": {} + '@tanstack/history@1.162.0': {} - "@tanstack/query-core@5.101.0": {} + '@tanstack/query-core@5.101.0': {} - "@tanstack/react-query@5.101.0(react@19.2.7)": + '@tanstack/react-query@5.101.0(react@19.2.7)': dependencies: - "@tanstack/query-core": 5.101.0 + '@tanstack/query-core': 5.101.0 react: 19.2.7 - "@tanstack/react-router@1.170.15(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@tanstack/react-router@1.170.15(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@tanstack/history": 1.162.0 - "@tanstack/react-store": 0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@tanstack/router-core": 1.171.13 + '@tanstack/history': 1.162.0 + '@tanstack/react-store': 0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/router-core': 1.171.13 isbot: 5.1.42 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - "@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@tanstack/store": 0.9.3 + '@tanstack/store': 0.9.3 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7) - "@tanstack/router-core@1.171.13": + '@tanstack/router-core@1.171.13': dependencies: - "@tanstack/history": 1.162.0 + '@tanstack/history': 1.162.0 cookie-es: 3.1.1 seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) - "@tanstack/router-generator@1.167.17": + '@tanstack/router-generator@1.167.17': dependencies: - "@babel/types": 7.29.7 - "@tanstack/router-core": 1.171.13 - "@tanstack/router-utils": 1.162.2 - "@tanstack/virtual-file-routes": 1.162.0 + '@babel/types': 7.29.7 + '@tanstack/router-core': 1.171.13 + '@tanstack/router-utils': 1.162.2 + '@tanstack/virtual-file-routes': 1.162.0 jiti: 2.7.0 magic-string: 0.30.21 prettier: 3.8.4 @@ -6645,29 +6277,29 @@ snapshots: transitivePeerDependencies: - supports-color - "@tanstack/router-plugin@1.168.18(@tanstack/react-router@1.170.15(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0))(webpack@5.107.2)": + '@tanstack/router-plugin@1.168.18(@tanstack/react-router@1.170.15(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0))(webpack@5.107.2)': dependencies: - "@babel/core": 7.29.7 - "@babel/template": 7.29.7 - "@babel/types": 7.29.7 - "@tanstack/router-core": 1.171.13 - "@tanstack/router-generator": 1.167.17 - "@tanstack/router-utils": 1.162.2 + '@babel/core': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + '@tanstack/router-core': 1.171.13 + '@tanstack/router-generator': 1.167.17 + '@tanstack/router-utils': 1.162.2 chokidar: 5.0.0 unplugin: 3.0.0 zod: 4.4.3 optionalDependencies: - "@tanstack/react-router": 1.170.15(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-router': 1.170.15(react-dom@19.2.7(react@19.2.7))(react@19.2.7) vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0) webpack: 5.107.2 transitivePeerDependencies: - supports-color - "@tanstack/router-utils@1.162.2": + '@tanstack/router-utils@1.162.2': dependencies: - "@babel/generator": 7.29.7 - "@babel/parser": 7.29.7 - "@babel/types": 7.29.7 + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 ansis: 4.3.1 babel-dead-code-elimination: 1.0.12 diff: 8.0.4 @@ -6676,271 +6308,283 @@ snapshots: transitivePeerDependencies: - supports-color - "@tanstack/store@0.9.3": {} + '@tanstack/store@0.9.3': {} - "@tanstack/virtual-file-routes@1.162.0": {} + '@tanstack/virtual-file-routes@1.162.0': {} - "@testing-library/dom@10.4.1": + '@testing-library/dom@10.4.1': dependencies: - "@babel/code-frame": 7.29.7 - "@babel/runtime": 7.29.7 - "@types/aria-query": 5.0.4 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 lz-string: 1.5.0 picocolors: 1.1.1 pretty-format: 27.5.1 - "@testing-library/jest-dom@6.9.1": + '@testing-library/jest-dom@6.9.1': dependencies: - "@adobe/css-tools": 4.5.0 + '@adobe/css-tools': 4.5.0 aria-query: 5.3.2 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 picocolors: 1.1.1 redent: 3.0.0 - "@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - "@babel/runtime": 7.29.7 - "@testing-library/dom": 10.4.1 + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) - "@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)": + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: - "@testing-library/dom": 10.4.1 + '@testing-library/dom': 10.4.1 - "@tootallnate/once@2.0.1": {} + '@tootallnate/once@2.0.1': {} - "@tybys/wasm-util@0.10.2": + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true - "@types/aria-query@5.0.4": {} + '@types/aria-query@5.0.4': {} - "@types/cacheable-request@6.0.3": + '@types/cacheable-request@6.0.3': dependencies: - "@types/http-cache-semantics": 4.2.0 - "@types/keyv": 3.1.4 - "@types/node": 25.9.2 - "@types/responselike": 1.0.3 + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 25.9.2 + '@types/responselike': 1.0.3 - "@types/chai@5.2.3": + '@types/chai@5.2.3': dependencies: - "@types/deep-eql": 4.0.2 + '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - "@types/deep-eql@4.0.2": {} + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} - "@types/estree@1.0.9": {} + '@types/estree@1.0.9': {} - "@types/fs-extra@9.0.13": + '@types/fs-extra@9.0.13': dependencies: - "@types/node": 25.9.2 - optional: true + '@types/node': 25.9.2 - "@types/http-cache-semantics@4.2.0": {} + '@types/http-cache-semantics@4.2.0': {} - "@types/json-schema@7.0.15": {} + '@types/json-schema@7.0.15': {} - "@types/keyv@3.1.4": + '@types/keyv@3.1.4': dependencies: - "@types/node": 25.9.2 + '@types/node': 25.9.2 + + '@types/ms@2.1.0': {} - "@types/mute-stream@0.0.4": + '@types/mute-stream@0.0.4': dependencies: - "@types/node": 22.19.20 + '@types/node': 25.9.2 - "@types/node@20.19.42": + '@types/node@20.19.42': dependencies: undici-types: 6.21.0 - "@types/node@22.19.20": + '@types/node@22.19.20': dependencies: undici-types: 6.21.0 - "@types/node@25.9.2": + '@types/node@25.9.2': dependencies: undici-types: 7.24.6 - "@types/react-dom@19.2.3(@types/react@19.2.17)": + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 - "@types/react@19.2.17": + '@types/react@19.2.17': dependencies: csstype: 3.2.3 - "@types/responselike@1.0.3": + '@types/responselike@1.0.3': dependencies: - "@types/node": 25.9.2 + '@types/node': 25.9.2 + + '@types/trusted-types@2.0.7': + optional: true - "@types/wrap-ansi@3.0.0": {} + '@types/wrap-ansi@3.0.0': {} - "@types/yauzl@2.10.3": + '@types/yauzl@2.10.3': dependencies: - "@types/node": 20.19.42 + '@types/node': 20.19.42 optional: true - "@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0))": + '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0))': dependencies: - "@rolldown/pluginutils": 1.0.1 + '@rolldown/pluginutils': 1.0.1 vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0) - "@vitest/expect@4.1.8": + '@vitest/expect@4.1.8': dependencies: - "@standard-schema/spec": 1.1.0 - "@types/chai": 5.2.3 - "@vitest/spy": 4.1.8 - "@vitest/utils": 4.1.8 + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 chai: 6.2.2 tinyrainbow: 3.1.0 - "@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0))": + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0))': dependencies: - "@vitest/spy": 4.1.8 + '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0) - "@vitest/pretty-format@4.1.8": + '@vitest/pretty-format@4.1.8': dependencies: tinyrainbow: 3.1.0 - "@vitest/runner@4.1.8": + '@vitest/runner@4.1.8': dependencies: - "@vitest/utils": 4.1.8 + '@vitest/utils': 4.1.8 pathe: 2.0.3 - "@vitest/snapshot@4.1.8": + '@vitest/snapshot@4.1.8': dependencies: - "@vitest/pretty-format": 4.1.8 - "@vitest/utils": 4.1.8 + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 magic-string: 0.30.21 pathe: 2.0.3 - "@vitest/spy@4.1.8": {} + '@vitest/spy@4.1.8': {} - "@vitest/utils@4.1.8": + '@vitest/utils@4.1.8': dependencies: - "@vitest/pretty-format": 4.1.8 + '@vitest/pretty-format': 4.1.8 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - "@vscode/sudo-prompt@9.3.2": {} + '@vscode/sudo-prompt@9.3.2': {} - "@webassemblyjs/ast@1.14.1": + '@webassemblyjs/ast@1.14.1': dependencies: - "@webassemblyjs/helper-numbers": 1.13.2 - "@webassemblyjs/helper-wasm-bytecode": 1.13.2 + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - "@webassemblyjs/floating-point-hex-parser@1.13.2": {} + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} - "@webassemblyjs/helper-api-error@1.13.2": {} + '@webassemblyjs/helper-api-error@1.13.2': {} - "@webassemblyjs/helper-buffer@1.14.1": {} + '@webassemblyjs/helper-buffer@1.14.1': {} - "@webassemblyjs/helper-numbers@1.13.2": + '@webassemblyjs/helper-numbers@1.13.2': dependencies: - "@webassemblyjs/floating-point-hex-parser": 1.13.2 - "@webassemblyjs/helper-api-error": 1.13.2 - "@xtuc/long": 4.2.2 + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 - "@webassemblyjs/helper-wasm-bytecode@1.13.2": {} + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} - "@webassemblyjs/helper-wasm-section@1.14.1": + '@webassemblyjs/helper-wasm-section@1.14.1': dependencies: - "@webassemblyjs/ast": 1.14.1 - "@webassemblyjs/helper-buffer": 1.14.1 - "@webassemblyjs/helper-wasm-bytecode": 1.13.2 - "@webassemblyjs/wasm-gen": 1.14.1 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 - "@webassemblyjs/ieee754@1.13.2": + '@webassemblyjs/ieee754@1.13.2': dependencies: - "@xtuc/ieee754": 1.2.0 + '@xtuc/ieee754': 1.2.0 - "@webassemblyjs/leb128@1.13.2": + '@webassemblyjs/leb128@1.13.2': dependencies: - "@xtuc/long": 4.2.2 + '@xtuc/long': 4.2.2 - "@webassemblyjs/utf8@1.13.2": {} + '@webassemblyjs/utf8@1.13.2': {} - "@webassemblyjs/wasm-edit@1.14.1": + '@webassemblyjs/wasm-edit@1.14.1': dependencies: - "@webassemblyjs/ast": 1.14.1 - "@webassemblyjs/helper-buffer": 1.14.1 - "@webassemblyjs/helper-wasm-bytecode": 1.13.2 - "@webassemblyjs/helper-wasm-section": 1.14.1 - "@webassemblyjs/wasm-gen": 1.14.1 - "@webassemblyjs/wasm-opt": 1.14.1 - "@webassemblyjs/wasm-parser": 1.14.1 - "@webassemblyjs/wast-printer": 1.14.1 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 - "@webassemblyjs/wasm-gen@1.14.1": + '@webassemblyjs/wasm-gen@1.14.1': dependencies: - "@webassemblyjs/ast": 1.14.1 - "@webassemblyjs/helper-wasm-bytecode": 1.13.2 - "@webassemblyjs/ieee754": 1.13.2 - "@webassemblyjs/leb128": 1.13.2 - "@webassemblyjs/utf8": 1.13.2 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 - "@webassemblyjs/wasm-opt@1.14.1": + '@webassemblyjs/wasm-opt@1.14.1': dependencies: - "@webassemblyjs/ast": 1.14.1 - "@webassemblyjs/helper-buffer": 1.14.1 - "@webassemblyjs/wasm-gen": 1.14.1 - "@webassemblyjs/wasm-parser": 1.14.1 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 - "@webassemblyjs/wasm-parser@1.14.1": + '@webassemblyjs/wasm-parser@1.14.1': dependencies: - "@webassemblyjs/ast": 1.14.1 - "@webassemblyjs/helper-api-error": 1.13.2 - "@webassemblyjs/helper-wasm-bytecode": 1.13.2 - "@webassemblyjs/ieee754": 1.13.2 - "@webassemblyjs/leb128": 1.13.2 - "@webassemblyjs/utf8": 1.13.2 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 - "@webassemblyjs/wast-printer@1.14.1": + '@webassemblyjs/wast-printer@1.14.1': dependencies: - "@webassemblyjs/ast": 1.14.1 - "@xtuc/long": 4.2.2 + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 - "@xmldom/xmldom@0.9.10": {} + '@xmldom/xmldom@0.8.13': {} - "@xterm/addon-canvas@0.7.0(@xterm/xterm@5.5.0)": + '@xmldom/xmldom@0.9.10': {} + + '@xterm/addon-canvas@0.7.0(@xterm/xterm@5.5.0)': dependencies: - "@xterm/xterm": 5.5.0 + '@xterm/xterm': 5.5.0 - "@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)": + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': dependencies: - "@xterm/xterm": 5.5.0 + '@xterm/xterm': 5.5.0 - "@xterm/addon-search@0.15.0(@xterm/xterm@5.5.0)": + '@xterm/addon-search@0.15.0(@xterm/xterm@5.5.0)': dependencies: - "@xterm/xterm": 5.5.0 + '@xterm/xterm': 5.5.0 - "@xterm/addon-unicode11@0.9.0": {} + '@xterm/addon-unicode11@0.9.0': {} - "@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)": + '@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)': dependencies: - "@xterm/xterm": 5.5.0 + '@xterm/xterm': 5.5.0 - "@xterm/addon-webgl@0.19.0": {} + '@xterm/addon-webgl@0.19.0': {} - "@xterm/xterm@5.5.0": {} + '@xterm/xterm@5.5.0': {} - "@xtuc/ieee754@1.2.0": {} + '@xtuc/ieee754@1.2.0': {} - "@xtuc/long@4.2.2": {} + '@xtuc/long@4.2.2': {} abbrev@1.1.1: {} + abbrev@4.0.0: {} + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -7004,6 +6648,54 @@ snapshots: ansis@4.3.1: {} + app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3): + dependencies: + '@electron/asar': 3.4.1 + '@electron/fuses': 1.8.0 + '@electron/get': 3.1.0 + '@electron/notarize': 2.5.0 + '@electron/osx-sign': 1.3.3 + '@electron/rebuild': 4.0.4 + '@electron/universal': 2.0.3 + '@malept/flatpak-bundler': 0.4.0 + '@noble/hashes': 2.2.0 + '@peculiar/webcrypto': 1.7.1 + '@types/fs-extra': 9.0.13 + ajv: 8.20.0 + asn1js: 3.0.10 + async-exit-hook: 2.0.1 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 + chromium-pickle-js: 0.2.0 + ci-info: 4.3.1 + debug: 4.4.3(supports-color@10.2.2) + dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) + dotenv: 16.6.1 + dotenv-expand: 11.0.7 + ejs: 3.1.10 + electron-builder-squirrel-windows: 26.15.3(dmg-builder@26.15.3) + electron-publish: 26.15.3 + fs-extra: 10.1.0 + hosted-git-info: 4.1.0 + isbinaryfile: 5.0.7 + jiti: 2.7.0 + js-yaml: 4.1.1 + json5: 2.2.3 + lazy-val: 1.0.5 + minimatch: 10.2.5 + pkijs: 3.4.0 + plist: 3.1.0 + proper-lockfile: 4.1.2 + resedit: 1.7.2 + semver: 7.7.4 + tar: 7.5.16 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + unzipper: 0.12.5 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -7016,23 +6708,39 @@ snapshots: aria-query@5.3.2: {} + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + assertion-error@2.0.1: {} + async-exit-hook@2.0.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + at-least-node@1.0.0: {} author-regex@1.0.0: {} + aws4@1.13.2: {} + babel-dead-code-elimination@1.0.12: dependencies: - "@babel/core": 7.29.7 - "@babel/parser": 7.29.7 - "@babel/traverse": 7.29.7 - "@babel/types": 7.29.7 + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.35: {} @@ -7065,6 +6773,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -7086,10 +6798,38 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + builder-util-runtime@9.7.0: + dependencies: + debug: 4.4.3(supports-color@10.2.2) + sax: 1.6.0 + transitivePeerDependencies: + - supports-color + + builder-util@26.15.3: + dependencies: + '@types/debug': 4.1.13 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@10.2.2) + fs-extra: 10.1.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-yaml: 4.1.1 + sanitize-filename: 1.6.4 + source-map-support: 0.5.21 + stat-mode: 1.0.0 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + transitivePeerDependencies: + - supports-color + + bytestreamjs@2.0.1: {} + cacache@16.1.3: dependencies: - "@npmcli/fs": 2.1.2 - "@npmcli/move-file": 2.0.1 + '@npmcli/fs': 2.1.2 + '@npmcli/move-file': 2.0.1 chownr: 2.0.0 fs-minipass: 2.1.0 glob: 8.1.0 @@ -7121,6 +6861,11 @@ snapshots: normalize-url: 6.1.0 responselike: 2.0.1 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + caniuse-lite@1.0.30001797: {} chai@6.2.2: {} @@ -7140,8 +6885,14 @@ snapshots: chownr@2.0.0: {} + chownr@3.0.0: {} + chrome-trace-event@1.0.4: {} + chromium-pickle-js@0.2.0: {} + + ci-info@4.3.1: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -7196,6 +6947,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@11.1.0: {} commander@2.20.3: {} @@ -7212,6 +6967,10 @@ snapshots: cookie-es@3.1.1: {} + core-js@3.49.0: {} + + core-util-is@1.0.3: {} + cross-dirname@0.1.0: {} cross-spawn@6.0.6: @@ -7239,12 +6998,12 @@ snapshots: csstype@3.2.3: {} - data-urls@7.0.0: + data-urls@7.0.0(@noble/hashes@2.2.0): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) transitivePeerDependencies: - - "@noble/hashes" + - '@noble/hashes' debug@2.6.9: dependencies: @@ -7282,6 +7041,8 @@ snapshots: object-keys: 1.1.1 optional: true + delayed-stream@1.0.0: {} + deprecation@2.3.1: {} dequal@2.0.3: {} @@ -7300,16 +7061,59 @@ snapshots: minimatch: 3.1.5 p-limit: 3.1.0 + dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): + dependencies: + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + fs-extra: 10.1.0 + js-yaml: 4.1.1 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + eastasianwidth@0.2.0: {} + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-builder-squirrel-windows@26.15.3(dmg-builder@26.15.3): + dependencies: + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + electron-winstaller: 5.4.0 + transitivePeerDependencies: + - dmg-builder + - supports-color + electron-installer-common@0.10.4: dependencies: - "@electron/asar": 3.4.1 - "@malept/cross-spawn-promise": 1.1.1 + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 1.1.1 debug: 4.4.3(supports-color@10.2.2) fs-extra: 9.1.0 glob: 7.2.3 @@ -7318,14 +7122,14 @@ snapshots: semver: 7.8.4 tmp-promise: 3.0.3 optionalDependencies: - "@types/fs-extra": 9.0.13 + '@types/fs-extra': 9.0.13 transitivePeerDependencies: - supports-color optional: true electron-installer-debian@3.2.0: dependencies: - "@malept/cross-spawn-promise": 1.1.1 + '@malept/cross-spawn-promise': 1.1.1 debug: 4.4.3(supports-color@10.2.2) electron-installer-common: 0.10.4 fs-extra: 9.1.0 @@ -7339,7 +7143,7 @@ snapshots: electron-installer-redhat@3.4.0: dependencies: - "@malept/cross-spawn-promise": 1.1.1 + '@malept/cross-spawn-promise': 1.1.1 debug: 4.4.3(supports-color@10.2.2) electron-installer-common: 0.10.4 fs-extra: 9.1.0 @@ -7350,25 +7154,38 @@ snapshots: - supports-color optional: true + electron-publish@26.15.3: + dependencies: + '@types/fs-extra': 9.0.13 + aws4: 1.13.2 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + form-data: 4.0.6 + fs-extra: 10.1.0 + lazy-val: 1.0.5 + mime: 2.6.0 + transitivePeerDependencies: + - supports-color + electron-to-chromium@1.5.371: {} electron-winstaller@5.4.0: dependencies: - "@electron/asar": 3.4.1 + '@electron/asar': 3.4.1 debug: 4.4.3(supports-color@10.2.2) fs-extra: 7.0.1 lodash: 4.18.1 temp: 0.9.4 optionalDependencies: - "@electron/windows-sign": 1.2.2 + '@electron/windows-sign': 1.2.2 transitivePeerDependencies: - supports-color - optional: true electron@33.4.11: dependencies: - "@electron/get": 2.0.3 - "@types/node": 20.19.42 + '@electron/get': 2.0.3 + '@types/node': 20.19.42 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -7401,13 +7218,23 @@ snapshots: dependencies: is-arrayish: 0.2.1 - es-define-property@1.0.1: - optional: true + es-define-property@1.0.1: {} es-errors@1.3.0: {} es-module-lexer@2.1.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + es6-error@4.1.1: optional: true @@ -7433,7 +7260,7 @@ snapshots: estree-walker@3.0.3: dependencies: - "@types/estree": 1.0.9 + '@types/estree': 1.0.9 eta@3.5.0: {} @@ -7467,7 +7294,7 @@ snapshots: get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: - "@types/yauzl": 2.10.3 + '@types/yauzl': 2.10.3 transitivePeerDependencies: - supports-color @@ -7475,8 +7302,8 @@ snapshots: fast-glob@3.3.3: dependencies: - "@nodelib/fs.stat": 2.0.5 - "@nodelib/fs.walk": 1.2.8 + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.8 @@ -7495,6 +7322,12 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.4.8: {} + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + filename-reserved-regex@2.0.0: {} filenamify@4.3.0: @@ -7523,12 +7356,26 @@ snapshots: transitivePeerDependencies: - supports-color + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 universalify: 2.0.1 + fs-extra@11.3.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-extra@11.3.5: dependencies: graceful-fs: 4.2.11 @@ -7540,7 +7387,6 @@ snapshots: graceful-fs: 4.2.11 jsonfile: 4.0.0 universalify: 0.1.2 - optional: true fs-extra@8.1.0: dependencies: @@ -7590,6 +7436,19 @@ snapshots: tiny-each-async: 2.0.3 optional: true + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} get-package-info@1.0.0: @@ -7601,6 +7460,11 @@ snapshots: transitivePeerDependencies: - supports-color + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-stream@4.1.0: dependencies: pump: 3.0.4 @@ -7656,15 +7520,14 @@ snapshots: gopd: 1.2.0 optional: true - gopd@1.2.0: - optional: true + gopd@1.2.0: {} got@11.8.6: dependencies: - "@sindresorhus/is": 4.6.0 - "@szmarczak/http-timer": 4.0.6 - "@types/cacheable-request": 6.0.3 - "@types/responselike": 1.0.3 + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 cacheable-lookup: 5.0.4 cacheable-request: 7.0.4 decompress-response: 6.0.0 @@ -7682,28 +7545,45 @@ snapshots: es-define-property: 1.0.1 optional: true + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.4: dependencies: function-bind: 1.1.2 hosted-git-info@2.8.9: {} - html-encoding-sniffer@6.0.0: + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): dependencies: - "@exodus/bytes": 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) transitivePeerDependencies: - - "@noble/hashes" + - '@noble/hashes' http-cache-semantics@4.2.0: {} http-proxy-agent@5.0.0: dependencies: - "@tootallnate/once": 2.0.1 + '@tootallnate/once': 2.0.1 agent-base: 6.0.2 debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + http2-wrapper@1.0.3: dependencies: quick-lru: 5.1.1 @@ -7789,15 +7669,29 @@ snapshots: is-url@1.2.4: {} + isarray@1.0.0: {} + isbinaryfile@4.0.10: {} + isbinaryfile@5.0.7: {} + isbot@5.1.42: {} isexe@2.0.0: {} + isexe@3.1.5: {} + + isexe@4.0.0: {} + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + jest-worker@27.5.1: dependencies: - "@types/node": 25.9.2 + '@types/node': 25.9.2 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -7811,17 +7705,17 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@29.1.1: + jsdom@29.1.1(@noble/hashes@2.2.0): dependencies: - "@asamuzakjp/css-color": 5.1.11 - "@asamuzakjp/dom-selector": 7.1.1 - "@bramus/specificity": 2.4.2 - "@csstools/css-syntax-patches-for-csstree": 1.1.5(css-tree@3.2.1) - "@exodus/bytes": 1.15.1 + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) css-tree: 3.2.1 - data-urls: 7.0.0 + data-urls: 7.0.0(@noble/hashes@2.2.0) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.1 parse5: 8.0.1 @@ -7832,10 +7726,10 @@ snapshots: w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - - "@noble/hashes" + - '@noble/hashes' jsesc@3.1.0: {} @@ -7864,6 +7758,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + lazy-val@1.0.5: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -7942,8 +7838,7 @@ snapshots: lodash.get@4.4.2: {} - lodash@4.18.1: - optional: true + lodash@4.18.1: {} log-symbols@4.1.0: dependencies: @@ -7966,6 +7861,10 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + lru-cache@7.18.3: {} lucide-react@1.17.0(react@19.2.7): @@ -7976,7 +7875,7 @@ snapshots: magic-string@0.30.21: dependencies: - "@jridgewell/sourcemap-codec": 1.5.5 + '@jridgewell/sourcemap-codec': 1.5.5 make-fetch-happen@10.2.1: dependencies: @@ -8009,6 +7908,8 @@ snapshots: escape-string-regexp: 4.0.0 optional: true + math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} mem@4.3.0: @@ -8034,6 +7935,8 @@ snapshots: dependencies: mime-db: 1.52.0 + mime@2.6.0: {} + mimic-fn@2.1.0: {} mimic-response@1.0.1: {} @@ -8042,6 +7945,10 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.15 @@ -8086,15 +7993,20 @@ snapshots: minipass@5.0.0: {} + minipass@7.1.3: {} + minizlib@2.1.2: dependencies: minipass: 3.3.6 yallist: 4.0.0 + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + mkdirp@0.5.6: dependencies: minimist: 1.2.8 - optional: true mkdirp@1.0.4: {} @@ -8116,6 +8028,10 @@ snapshots: dependencies: semver: 7.8.4 + node-abi@4.31.0: + dependencies: + semver: 7.8.4 + node-api-version@0.2.1: dependencies: semver: 7.8.4 @@ -8126,12 +8042,31 @@ snapshots: optionalDependencies: encoding: 0.1.13 + node-gyp@12.4.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.8.4 + tar: 7.5.16 + tinyglobby: 0.2.17 + undici: 6.27.0 + which: 6.0.1 + + node-int64@0.4.0: {} + node-releases@2.0.47: {} nopt@6.0.0: dependencies: abbrev: 1.1.1 + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 @@ -8166,7 +8101,7 @@ snapshots: openapi-typescript@7.13.0(typescript@5.9.3): dependencies: - "@redocly/openapi-core": 1.34.15(supports-color@10.2.2) + '@redocly/openapi-core': 1.34.15(supports-color@10.2.2) ansi-colors: 4.1.3 change-case: 5.4.4 parse-json: 8.3.0 @@ -8228,7 +8163,7 @@ snapshots: parse-json@8.3.0: dependencies: - "@babel/code-frame": 7.29.7 + '@babel/code-frame': 7.29.7 index-to-position: 1.2.0 type-fest: 4.41.0 @@ -8254,6 +8189,8 @@ snapshots: pathe@2.0.3: {} + pe-library@0.4.1: {} + pe-library@1.0.1: {} pend@1.2.0: {} @@ -8266,6 +8203,15 @@ snapshots: pify@2.3.0: {} + pkijs@3.4.0: + dependencies: + '@noble/hashes': 1.4.0 + asn1js: 3.0.10 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + playwright-core@1.60.0: {} playwright@1.60.0: @@ -8274,9 +8220,15 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + plist@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + plist@3.1.1: dependencies: - "@xmldom/xmldom": 0.9.10 + '@xmldom/xmldom': 0.9.10 base64-js: 1.5.1 xmlbuilder: 15.1.1 @@ -8288,10 +8240,23 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-js@1.393.0: + dependencies: + '@posthog/core': 1.37.1 + '@posthog/types': 1.391.0 + core-js: 3.49.0 + dompurify: 3.4.11 + fflate: 0.4.8 + preact: 10.29.2 + query-selector-shadow-dom: 1.0.1 + web-vitals: 5.3.0 + postject@1.0.0-alpha.6: dependencies: commander: 9.5.0 + preact@10.29.2: {} + prettier@3.8.4: {} pretty-format@27.5.1: @@ -8302,6 +8267,10 @@ snapshots: proc-log@2.0.1: {} + proc-log@6.1.0: {} + + process-nextick-args@2.0.1: {} + progress@2.0.3: {} promise-inflight@1.0.1: {} @@ -8311,6 +8280,12 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -8318,72 +8293,80 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + + query-selector-shadow-dom@1.0.1: {} + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} radix-ui@1.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - "@radix-ui/primitive": 1.1.4 - "@radix-ui/react-accessible-icon": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-accordion": 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-alert-dialog": 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-arrow": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-aspect-ratio": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-avatar": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-checkbox": 1.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-collapsible": 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-collection": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-compose-refs": 1.1.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-context-menu": 2.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-dialog": 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-direction": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-dismissable-layer": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-dropdown-menu": 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-focus-guards": 1.1.4(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-focus-scope": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-form": 0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-hover-card": 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-label": 2.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-menu": 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-menubar": 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-navigation-menu": 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-one-time-password-field": 0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-password-toggle-field": 0.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-popover": 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-popper": 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-portal": 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-presence": 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-primitive": 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-progress": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-radio-group": 1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-roving-focus": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-scroll-area": 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-select": 2.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-separator": 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-slider": 1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-slot": 1.2.5(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-switch": 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-tabs": 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-toast": 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-toggle": 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-toggle-group": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-toolbar": 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-tooltip": 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - "@radix-ui/react-use-callback-ref": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-controllable-state": 1.2.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-effect-event": 0.0.3(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-escape-keydown": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-is-hydrated": 0.1.1(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-layout-effect": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-use-size": 1.1.2(@types/react@19.2.17)(react@19.2.7) - "@radix-ui/react-visually-hidden": 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-accessible-icon': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-accordion': 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-aspect-ratio': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-avatar': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-form': 0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-label': 2.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menu': 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menubar': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-one-time-password-field': 0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-password-toggle-field': 0.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-progress': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-select': 2.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slider': 1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-switch': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toast': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toolbar': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 - "@types/react-dom": 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) react-dom@19.2.7(react@19.2.7): dependencies: @@ -8398,7 +8381,7 @@ snapshots: react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) tslib: 2.8.1 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7): dependencies: @@ -8409,7 +8392,7 @@ snapshots: use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7) use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7) optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 react-resizable-panels@4.11.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: @@ -8422,7 +8405,7 @@ snapshots: react: 19.2.7 tslib: 2.8.1 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 react@19.2.7: {} @@ -8443,6 +8426,16 @@ snapshots: normalize-package-data: 2.5.0 path-type: 2.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -8464,6 +8457,10 @@ snapshots: require-from-string@2.0.2: {} + resedit@1.7.2: + dependencies: + pe-library: 0.4.1 + resedit@2.0.3: dependencies: pe-library: 1.0.1 @@ -8500,7 +8497,6 @@ snapshots: rimraf@2.6.3: dependencies: glob: 7.2.3 - optional: true rimraf@3.0.2: dependencies: @@ -8518,33 +8514,41 @@ snapshots: rolldown@1.0.3: dependencies: - "@oxc-project/types": 0.133.0 - "@rolldown/pluginutils": 1.0.1 + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 optionalDependencies: - "@rolldown/binding-android-arm64": 1.0.3 - "@rolldown/binding-darwin-arm64": 1.0.3 - "@rolldown/binding-darwin-x64": 1.0.3 - "@rolldown/binding-freebsd-x64": 1.0.3 - "@rolldown/binding-linux-arm-gnueabihf": 1.0.3 - "@rolldown/binding-linux-arm64-gnu": 1.0.3 - "@rolldown/binding-linux-arm64-musl": 1.0.3 - "@rolldown/binding-linux-ppc64-gnu": 1.0.3 - "@rolldown/binding-linux-s390x-gnu": 1.0.3 - "@rolldown/binding-linux-x64-gnu": 1.0.3 - "@rolldown/binding-linux-x64-musl": 1.0.3 - "@rolldown/binding-openharmony-arm64": 1.0.3 - "@rolldown/binding-wasm32-wasi": 1.0.3 - "@rolldown/binding-win32-arm64-msvc": 1.0.3 - "@rolldown/binding-win32-x64-msvc": 1.0.3 + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + + sax@1.6.0: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -8553,7 +8557,7 @@ snapshots: schema-utils@4.3.3: dependencies: - "@types/json-schema": 7.0.15 + '@types/json-schema': 7.0.15 ajv: 8.20.0 ajv-formats: 2.1.1(ajv@8.20.0) ajv-keywords: 5.1.0(ajv@8.20.0) @@ -8565,6 +8569,8 @@ snapshots: semver@6.3.1: {} + semver@7.7.4: {} + semver@7.8.4: {} serialize-error@7.0.1: @@ -8648,6 +8654,8 @@ snapshots: stackback@0.0.2: {} + stat-mode@1.0.0: {} + std-env@4.1.0: {} string-width@4.2.3: @@ -8662,6 +8670,10 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.2.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -8721,15 +8733,27 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 + tar@7.5.16: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + temp-file@3.4.0: + dependencies: + async-exit-hook: 2.0.1 + fs-extra: 10.1.0 + temp@0.9.4: dependencies: mkdirp: 0.5.6 rimraf: 2.6.3 - optional: true terser-webpack-plugin@5.6.1(webpack@5.107.2): dependencies: - "@jridgewell/trace-mapping": 0.3.31 + '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.48.0 @@ -8737,11 +8761,15 @@ snapshots: terser@5.48.0: dependencies: - "@jridgewell/source-map": 0.3.11 + '@jridgewell/source-map': 0.3.11 acorn: 8.16.0 commander: 2.20.3 source-map-support: 0.5.21 + tiny-async-pool@1.3.0: + dependencies: + semver: 5.7.2 + tiny-each-async@2.0.3: optional: true @@ -8765,14 +8793,12 @@ snapshots: tmp-promise@3.0.3: dependencies: tmp: 0.2.7 - optional: true tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 - tmp@0.2.7: - optional: true + tmp@0.2.7: {} to-regex-range@5.0.1: dependencies: @@ -8792,6 +8818,10 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + tslib@2.8.1: {} type-fest@0.13.1: @@ -8811,6 +8841,8 @@ snapshots: undici-types@7.24.6: {} + undici@6.27.0: {} + undici@7.27.2: {} unique-filename@2.0.1: @@ -8829,10 +8861,18 @@ snapshots: unplugin@3.0.0: dependencies: - "@jridgewell/remapping": 2.3.5 + '@jridgewell/remapping': 2.3.5 picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 + unzipper@0.12.5: + dependencies: + bluebird: 3.7.2 + duplexer2: 0.1.4 + fs-extra: 11.3.1 + graceful-fs: 4.2.11 + node-int64: 0.4.0 + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -8851,7 +8891,7 @@ snapshots: react: 19.2.7 tslib: 2.8.1 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): dependencies: @@ -8859,7 +8899,7 @@ snapshots: react: 19.2.7 tslib: 2.8.1 optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 use-sync-external-store@1.6.0(react@19.2.7): dependencies: @@ -8870,6 +8910,8 @@ snapshots: execa: 1.0.0 mem: 4.3.0 + utf8-byte-length@1.0.5: {} + util-deprecate@1.0.2: {} validate-npm-package-license@3.0.4: @@ -8885,20 +8927,20 @@ snapshots: rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: - "@types/node": 25.9.2 + '@types/node': 25.9.2 fsevents: 2.3.3 jiti: 2.7.0 terser: 5.48.0 - vitest@4.1.8(@types/node@25.9.2)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)): + vitest@4.1.8(@types/node@25.9.2)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)): dependencies: - "@vitest/expect": 4.1.8 - "@vitest/mocker": 4.1.8(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)) - "@vitest/pretty-format": 4.1.8 - "@vitest/runner": 4.1.8 - "@vitest/snapshot": 4.1.8 - "@vitest/spy": 4.1.8 - "@vitest/utils": 4.1.8 + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -8913,8 +8955,8 @@ snapshots: vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0) why-is-node-running: 2.3.0 optionalDependencies: - "@types/node": 25.9.2 - jsdom: 29.1.1 + '@types/node': 25.9.2 + jsdom: 29.1.1(@noble/hashes@2.2.0) transitivePeerDependencies: - msw @@ -8931,6 +8973,16 @@ snapshots: dependencies: defaults: 1.0.4 + web-vitals@5.3.0: {} + + webcrypto-core@1.9.2: + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + webidl-conversions@3.0.1: {} webidl-conversions@8.0.1: {} @@ -8941,11 +8993,11 @@ snapshots: webpack@5.107.2: dependencies: - "@types/estree": 1.0.9 - "@types/json-schema": 7.0.15 - "@webassemblyjs/ast": 1.14.1 - "@webassemblyjs/wasm-edit": 1.14.1 - "@webassemblyjs/wasm-parser": 1.14.1 + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.16.0 acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.2 @@ -8965,10 +9017,10 @@ snapshots: watchpack: 2.5.1 webpack-sources: 3.5.0 transitivePeerDependencies: - - "@minify-html/node" - - "@swc/core" - - "@swc/css" - - "@swc/html" + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' - clean-css - cssnano - csso @@ -8980,13 +9032,13 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@16.0.1: + whatwg-url@16.0.1(@noble/hashes@2.2.0): dependencies: - "@exodus/bytes": 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: - - "@noble/hashes" + - '@noble/hashes' whatwg-url@5.0.0: dependencies: @@ -9001,6 +9053,14 @@ snapshots: dependencies: isexe: 2.0.0 + which@5.0.0: + dependencies: + isexe: 3.1.5 + + which@6.0.1: + dependencies: + isexe: 4.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -9041,6 +9101,8 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: {} + yaml-ast-parser@0.0.43: {} yargs-parser@20.2.9: @@ -9082,6 +9144,6 @@ snapshots: zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): optionalDependencies: - "@types/react": 19.2.17 + '@types/react': 19.2.17 react: 19.2.7 use-sync-external-store: 1.6.0(react@19.2.7) diff --git a/frontend/src/renderer/routeTree.gen.ts b/frontend/src/renderer/routeTree.gen.ts index ffe4cdc4..6a13a2de 100644 --- a/frontend/src/renderer/routeTree.gen.ts +++ b/frontend/src/renderer/routeTree.gen.ts @@ -8,183 +8,188 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from "./routes/__root"; -import { Route as ShellRouteImport } from "./routes/_shell"; -import { Route as ShellIndexRouteImport } from "./routes/_shell.index"; -import { Route as ShellPrsRouteImport } from "./routes/_shell.prs"; -import { Route as ShellSessionsSessionIdRouteImport } from "./routes/_shell.sessions.$sessionId"; -import { Route as ShellProjectsProjectIdRouteImport } from "./routes/_shell.projects.$projectId"; -import { Route as ShellProjectsProjectIdSettingsRouteImport } from "./routes/_shell.projects.$projectId_.settings"; -import { Route as ShellProjectsProjectIdSessionsSessionIdRouteImport } from "./routes/_shell.projects.$projectId_.sessions.$sessionId"; +import { Route as rootRouteImport } from './routes/__root' +import { Route as ShellRouteImport } from './routes/_shell' +import { Route as ShellIndexRouteImport } from './routes/_shell.index' +import { Route as ShellPrsRouteImport } from './routes/_shell.prs' +import { Route as ShellSessionsSessionIdRouteImport } from './routes/_shell.sessions.$sessionId' +import { Route as ShellProjectsProjectIdRouteImport } from './routes/_shell.projects.$projectId' +import { Route as ShellProjectsProjectIdSettingsRouteImport } from './routes/_shell.projects.$projectId_.settings' +import { Route as ShellProjectsProjectIdSessionsSessionIdRouteImport } from './routes/_shell.projects.$projectId_.sessions.$sessionId' const ShellRoute = ShellRouteImport.update({ - id: "/_shell", - getParentRoute: () => rootRouteImport, -} as any); + id: '/_shell', + getParentRoute: () => rootRouteImport, +} as any) const ShellIndexRoute = ShellIndexRouteImport.update({ - id: "/", - path: "/", - getParentRoute: () => ShellRoute, -} as any); + id: '/', + path: '/', + getParentRoute: () => ShellRoute, +} as any) const ShellPrsRoute = ShellPrsRouteImport.update({ - id: "/prs", - path: "/prs", - getParentRoute: () => ShellRoute, -} as any); + id: '/prs', + path: '/prs', + getParentRoute: () => ShellRoute, +} as any) const ShellSessionsSessionIdRoute = ShellSessionsSessionIdRouteImport.update({ - id: "/sessions/$sessionId", - path: "/sessions/$sessionId", - getParentRoute: () => ShellRoute, -} as any); + id: '/sessions/$sessionId', + path: '/sessions/$sessionId', + getParentRoute: () => ShellRoute, +} as any) const ShellProjectsProjectIdRoute = ShellProjectsProjectIdRouteImport.update({ - id: "/projects/$projectId", - path: "/projects/$projectId", - getParentRoute: () => ShellRoute, -} as any); -const ShellProjectsProjectIdSettingsRoute = ShellProjectsProjectIdSettingsRouteImport.update({ - id: "/projects/$projectId_/settings", - path: "/projects/$projectId/settings", - getParentRoute: () => ShellRoute, -} as any); -const ShellProjectsProjectIdSessionsSessionIdRoute = ShellProjectsProjectIdSessionsSessionIdRouteImport.update({ - id: "/projects/$projectId_/sessions/$sessionId", - path: "/projects/$projectId/sessions/$sessionId", - getParentRoute: () => ShellRoute, -} as any); + id: '/projects/$projectId', + path: '/projects/$projectId', + getParentRoute: () => ShellRoute, +} as any) +const ShellProjectsProjectIdSettingsRoute = + ShellProjectsProjectIdSettingsRouteImport.update({ + id: '/projects/$projectId_/settings', + path: '/projects/$projectId/settings', + getParentRoute: () => ShellRoute, + } as any) +const ShellProjectsProjectIdSessionsSessionIdRoute = + ShellProjectsProjectIdSessionsSessionIdRouteImport.update({ + id: '/projects/$projectId_/sessions/$sessionId', + path: '/projects/$projectId/sessions/$sessionId', + getParentRoute: () => ShellRoute, + } as any) export interface FileRoutesByFullPath { - "/": typeof ShellIndexRoute; - "/prs": typeof ShellPrsRoute; - "/projects/$projectId": typeof ShellProjectsProjectIdRoute; - "/sessions/$sessionId": typeof ShellSessionsSessionIdRoute; - "/projects/$projectId/settings": typeof ShellProjectsProjectIdSettingsRoute; - "/projects/$projectId/sessions/$sessionId": typeof ShellProjectsProjectIdSessionsSessionIdRoute; + '/': typeof ShellIndexRoute + '/prs': typeof ShellPrsRoute + '/projects/$projectId': typeof ShellProjectsProjectIdRoute + '/sessions/$sessionId': typeof ShellSessionsSessionIdRoute + '/projects/$projectId/settings': typeof ShellProjectsProjectIdSettingsRoute + '/projects/$projectId/sessions/$sessionId': typeof ShellProjectsProjectIdSessionsSessionIdRoute } export interface FileRoutesByTo { - "/prs": typeof ShellPrsRoute; - "/": typeof ShellIndexRoute; - "/projects/$projectId": typeof ShellProjectsProjectIdRoute; - "/sessions/$sessionId": typeof ShellSessionsSessionIdRoute; - "/projects/$projectId/settings": typeof ShellProjectsProjectIdSettingsRoute; - "/projects/$projectId/sessions/$sessionId": typeof ShellProjectsProjectIdSessionsSessionIdRoute; + '/prs': typeof ShellPrsRoute + '/': typeof ShellIndexRoute + '/projects/$projectId': typeof ShellProjectsProjectIdRoute + '/sessions/$sessionId': typeof ShellSessionsSessionIdRoute + '/projects/$projectId/settings': typeof ShellProjectsProjectIdSettingsRoute + '/projects/$projectId/sessions/$sessionId': typeof ShellProjectsProjectIdSessionsSessionIdRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport; - "/_shell": typeof ShellRouteWithChildren; - "/_shell/prs": typeof ShellPrsRoute; - "/_shell/": typeof ShellIndexRoute; - "/_shell/projects/$projectId": typeof ShellProjectsProjectIdRoute; - "/_shell/sessions/$sessionId": typeof ShellSessionsSessionIdRoute; - "/_shell/projects/$projectId_/settings": typeof ShellProjectsProjectIdSettingsRoute; - "/_shell/projects/$projectId_/sessions/$sessionId": typeof ShellProjectsProjectIdSessionsSessionIdRoute; + __root__: typeof rootRouteImport + '/_shell': typeof ShellRouteWithChildren + '/_shell/prs': typeof ShellPrsRoute + '/_shell/': typeof ShellIndexRoute + '/_shell/projects/$projectId': typeof ShellProjectsProjectIdRoute + '/_shell/sessions/$sessionId': typeof ShellSessionsSessionIdRoute + '/_shell/projects/$projectId_/settings': typeof ShellProjectsProjectIdSettingsRoute + '/_shell/projects/$projectId_/sessions/$sessionId': typeof ShellProjectsProjectIdSessionsSessionIdRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: - | "/" - | "/prs" - | "/projects/$projectId" - | "/sessions/$sessionId" - | "/projects/$projectId/settings" - | "/projects/$projectId/sessions/$sessionId"; - fileRoutesByTo: FileRoutesByTo; - to: - | "/prs" - | "/" - | "/projects/$projectId" - | "/sessions/$sessionId" - | "/projects/$projectId/settings" - | "/projects/$projectId/sessions/$sessionId"; - id: - | "__root__" - | "/_shell" - | "/_shell/prs" - | "/_shell/" - | "/_shell/projects/$projectId" - | "/_shell/sessions/$sessionId" - | "/_shell/projects/$projectId_/settings" - | "/_shell/projects/$projectId_/sessions/$sessionId"; - fileRoutesById: FileRoutesById; + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/prs' + | '/projects/$projectId' + | '/sessions/$sessionId' + | '/projects/$projectId/settings' + | '/projects/$projectId/sessions/$sessionId' + fileRoutesByTo: FileRoutesByTo + to: + | '/prs' + | '/' + | '/projects/$projectId' + | '/sessions/$sessionId' + | '/projects/$projectId/settings' + | '/projects/$projectId/sessions/$sessionId' + id: + | '__root__' + | '/_shell' + | '/_shell/prs' + | '/_shell/' + | '/_shell/projects/$projectId' + | '/_shell/sessions/$sessionId' + | '/_shell/projects/$projectId_/settings' + | '/_shell/projects/$projectId_/sessions/$sessionId' + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - ShellRoute: typeof ShellRouteWithChildren; + ShellRoute: typeof ShellRouteWithChildren } -declare module "@tanstack/react-router" { - interface FileRoutesByPath { - "/_shell": { - id: "/_shell"; - path: ""; - fullPath: "/"; - preLoaderRoute: typeof ShellRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/_shell/": { - id: "/_shell/"; - path: "/"; - fullPath: "/"; - preLoaderRoute: typeof ShellIndexRouteImport; - parentRoute: typeof ShellRoute; - }; - "/_shell/prs": { - id: "/_shell/prs"; - path: "/prs"; - fullPath: "/prs"; - preLoaderRoute: typeof ShellPrsRouteImport; - parentRoute: typeof ShellRoute; - }; - "/_shell/sessions/$sessionId": { - id: "/_shell/sessions/$sessionId"; - path: "/sessions/$sessionId"; - fullPath: "/sessions/$sessionId"; - preLoaderRoute: typeof ShellSessionsSessionIdRouteImport; - parentRoute: typeof ShellRoute; - }; - "/_shell/projects/$projectId": { - id: "/_shell/projects/$projectId"; - path: "/projects/$projectId"; - fullPath: "/projects/$projectId"; - preLoaderRoute: typeof ShellProjectsProjectIdRouteImport; - parentRoute: typeof ShellRoute; - }; - "/_shell/projects/$projectId_/settings": { - id: "/_shell/projects/$projectId_/settings"; - path: "/projects/$projectId/settings"; - fullPath: "/projects/$projectId/settings"; - preLoaderRoute: typeof ShellProjectsProjectIdSettingsRouteImport; - parentRoute: typeof ShellRoute; - }; - "/_shell/projects/$projectId_/sessions/$sessionId": { - id: "/_shell/projects/$projectId_/sessions/$sessionId"; - path: "/projects/$projectId/sessions/$sessionId"; - fullPath: "/projects/$projectId/sessions/$sessionId"; - preLoaderRoute: typeof ShellProjectsProjectIdSessionsSessionIdRouteImport; - parentRoute: typeof ShellRoute; - }; - } +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/_shell': { + id: '/_shell' + path: '' + fullPath: '/' + preLoaderRoute: typeof ShellRouteImport + parentRoute: typeof rootRouteImport + } + '/_shell/': { + id: '/_shell/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof ShellIndexRouteImport + parentRoute: typeof ShellRoute + } + '/_shell/prs': { + id: '/_shell/prs' + path: '/prs' + fullPath: '/prs' + preLoaderRoute: typeof ShellPrsRouteImport + parentRoute: typeof ShellRoute + } + '/_shell/sessions/$sessionId': { + id: '/_shell/sessions/$sessionId' + path: '/sessions/$sessionId' + fullPath: '/sessions/$sessionId' + preLoaderRoute: typeof ShellSessionsSessionIdRouteImport + parentRoute: typeof ShellRoute + } + '/_shell/projects/$projectId': { + id: '/_shell/projects/$projectId' + path: '/projects/$projectId' + fullPath: '/projects/$projectId' + preLoaderRoute: typeof ShellProjectsProjectIdRouteImport + parentRoute: typeof ShellRoute + } + '/_shell/projects/$projectId_/settings': { + id: '/_shell/projects/$projectId_/settings' + path: '/projects/$projectId/settings' + fullPath: '/projects/$projectId/settings' + preLoaderRoute: typeof ShellProjectsProjectIdSettingsRouteImport + parentRoute: typeof ShellRoute + } + '/_shell/projects/$projectId_/sessions/$sessionId': { + id: '/_shell/projects/$projectId_/sessions/$sessionId' + path: '/projects/$projectId/sessions/$sessionId' + fullPath: '/projects/$projectId/sessions/$sessionId' + preLoaderRoute: typeof ShellProjectsProjectIdSessionsSessionIdRouteImport + parentRoute: typeof ShellRoute + } + } } interface ShellRouteChildren { - ShellPrsRoute: typeof ShellPrsRoute; - ShellIndexRoute: typeof ShellIndexRoute; - ShellProjectsProjectIdRoute: typeof ShellProjectsProjectIdRoute; - ShellSessionsSessionIdRoute: typeof ShellSessionsSessionIdRoute; - ShellProjectsProjectIdSettingsRoute: typeof ShellProjectsProjectIdSettingsRoute; - ShellProjectsProjectIdSessionsSessionIdRoute: typeof ShellProjectsProjectIdSessionsSessionIdRoute; + ShellPrsRoute: typeof ShellPrsRoute + ShellIndexRoute: typeof ShellIndexRoute + ShellProjectsProjectIdRoute: typeof ShellProjectsProjectIdRoute + ShellSessionsSessionIdRoute: typeof ShellSessionsSessionIdRoute + ShellProjectsProjectIdSettingsRoute: typeof ShellProjectsProjectIdSettingsRoute + ShellProjectsProjectIdSessionsSessionIdRoute: typeof ShellProjectsProjectIdSessionsSessionIdRoute } const ShellRouteChildren: ShellRouteChildren = { - ShellPrsRoute: ShellPrsRoute, - ShellIndexRoute: ShellIndexRoute, - ShellProjectsProjectIdRoute: ShellProjectsProjectIdRoute, - ShellSessionsSessionIdRoute: ShellSessionsSessionIdRoute, - ShellProjectsProjectIdSettingsRoute: ShellProjectsProjectIdSettingsRoute, - ShellProjectsProjectIdSessionsSessionIdRoute: ShellProjectsProjectIdSessionsSessionIdRoute, -}; + ShellPrsRoute: ShellPrsRoute, + ShellIndexRoute: ShellIndexRoute, + ShellProjectsProjectIdRoute: ShellProjectsProjectIdRoute, + ShellSessionsSessionIdRoute: ShellSessionsSessionIdRoute, + ShellProjectsProjectIdSettingsRoute: ShellProjectsProjectIdSettingsRoute, + ShellProjectsProjectIdSessionsSessionIdRoute: + ShellProjectsProjectIdSessionsSessionIdRoute, +} -const ShellRouteWithChildren = ShellRoute._addFileChildren(ShellRouteChildren); +const ShellRouteWithChildren = ShellRoute._addFileChildren(ShellRouteChildren) const rootRouteChildren: RootRouteChildren = { - ShellRoute: ShellRouteWithChildren, -}; -export const routeTree = rootRouteImport._addFileChildren(rootRouteChildren)._addFileTypes(); + ShellRoute: ShellRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() From aeebf44d32f756b980a6e1b0a74c51bdbb42660b Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 05:22:06 +0530 Subject: [PATCH 27/60] feat(workspace): add ForceDestroy for shutdown-path worktree removal Adds ForceDestroy(ctx, info) to ports.Workspace and the gitworktree adapter. It runs `git worktree remove --force`, then prune, then os.RemoveAll as a backstop. A new worktreeForceRemoveArgs builder in commands.go emits --force; the existing worktreeRemoveArgs is untouched so Destroy still refuses dirty worktrees via ErrWorkspaceDirty. TDD: test first creates a dirty worktree, confirms Destroy refuses with ErrWorkspaceDirty, then confirms ForceDestroy succeeds and the path is gone and deregistered. All 1609 backend tests pass. Co-Authored-By: Claude Opus 4.8 --- .../workspace/gitworktree/commands.go | 8 ++ .../workspace/gitworktree/workspace.go | 38 ++++++++ .../workspace_forcedestroy_test.go | 96 +++++++++++++++++++ .../integration/lifecycle_sqlite_test.go | 1 + backend/internal/ports/outbound.go | 5 + .../internal/session_manager/manager_test.go | 1 + 6 files changed, 149 insertions(+) create mode 100644 backend/internal/adapters/workspace/gitworktree/workspace_forcedestroy_test.go diff --git a/backend/internal/adapters/workspace/gitworktree/commands.go b/backend/internal/adapters/workspace/gitworktree/commands.go index 287bc661..4506e3a7 100644 --- a/backend/internal/adapters/workspace/gitworktree/commands.go +++ b/backend/internal/adapters/workspace/gitworktree/commands.go @@ -27,6 +27,14 @@ func worktreeRemoveArgs(repo, path string) []string { return []string{"-C", repo, "worktree", "remove", path} } +// worktreeForceRemoveArgs passes --force to bypass git's dirty-worktree check. +// Only ForceDestroy may call this. It is safe only AFTER the session's +// uncommitted work has been captured (Task 2's StashUncommitted). Callers that +// have not yet captured work must use worktreeRemoveArgs / Destroy instead. +func worktreeForceRemoveArgs(repo, path string) []string { + return []string{"-C", repo, "worktree", "remove", "--force", path} +} + func worktreePruneArgs(repo string) []string { return []string{"-C", repo, "worktree", "prune"} } diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index d42b3363..e7352c9b 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -185,6 +185,44 @@ func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error return nil } +// ForceDestroy removes the session's worktree unconditionally (--force), prunes +// it from git's worktree list, and falls back to os.RemoveAll if any filesystem +// residue remains. +// +// ponytail: only safe to call AFTER the session's uncommitted work has been +// captured by Task 2's StashUncommitted. Calling it before capture silently +// discards agent work. For interactive teardown (ao session kill, ao cleanup) +// use Destroy, which refuses dirty worktrees via ErrWorkspaceDirty. +func (w *Workspace) ForceDestroy(ctx context.Context, info ports.WorkspaceInfo) error { + if info.ProjectID == "" { + return errors.New("gitworktree: project id is required") + } + if info.Path == "" { + return fmt.Errorf("%w: empty path", ErrUnsafePath) + } + repo, err := w.repoPath(info.ProjectID) + if err != nil { + return err + } + path, err := w.validateManagedPath(info.Path) + if err != nil { + return err + } + // --force bypasses git's dirty check; errors here are advisory (the path may + // already be gone). We proceed to prune regardless. + _, _ = w.run(ctx, w.binary, worktreeForceRemoveArgs(repo, path)...) + if _, err := w.run(ctx, w.binary, worktreePruneArgs(repo)...); err != nil { + return fmt.Errorf("gitworktree: worktree prune: %w", err) + } + // os.RemoveAll as a backstop: cleans up filesystem residue left behind if + // git worktree remove --force still left the directory (e.g. files outside + // git tracking). + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("gitworktree: force remove path %q: %w", path, err) + } + return nil +} + // Restore re-attaches to an existing worktree for the session if one is still // present, recreating the handle without disturbing its contents. func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_forcedestroy_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_forcedestroy_test.go new file mode 100644 index 00000000..693ba823 --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/workspace_forcedestroy_test.go @@ -0,0 +1,96 @@ +package gitworktree + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// TestWorkspaceIntegrationForceDestroyDirtyWorktree is the RED/GREEN test for +// ForceDestroy. It creates a real git worktree, dirties it with an uncommitted +// file (which normal Destroy refuses via ErrWorkspaceDirty), then calls +// ForceDestroy and asserts: +// +// (a) the worktree path no longer exists on disk. +// (b) the worktree is deregistered from `git worktree list`. +func TestWorkspaceIntegrationForceDestroyDirtyWorktree(t *testing.T) { + git := requireGit(t) + tmp := t.TempDir() + repo := setupOriginClone(t, git, tmp) + root := filepath.Join(tmp, "managed") + ws, err := New(Options{Binary: git, ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + ctx := context.Background() + cfg := ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess-fd", Branch: "feature/force-destroy"} + + info, err := ws.Create(ctx, cfg) + if err != nil { + t.Fatalf("create: %v", err) + } + + // Dirty the worktree with uncommitted work: normal Destroy must refuse this. + wip := filepath.Join(info.Path, "wip.txt") + if err := os.WriteFile(wip, []byte("uncommitted work\n"), 0o600); err != nil { + t.Fatalf("write wip: %v", err) + } + + // Confirm that safe Destroy refuses the dirty worktree (guard: this is the + // contract we must NOT break). + if destroyErr := ws.Destroy(ctx, info); !errors.Is(destroyErr, ports.ErrWorkspaceDirty) { + t.Fatalf("Destroy dirty error = %v, want ports.ErrWorkspaceDirty", destroyErr) + } + // Path must still be intact after refused Destroy. + if _, err := os.Stat(wip); err != nil { + t.Fatalf("dirty worktree was removed by Destroy: %v", err) + } + + // ForceDestroy must succeed even though the worktree is dirty. + if err := ws.ForceDestroy(ctx, info); err != nil { + t.Fatalf("ForceDestroy: %v", err) + } + + // (a) Path no longer exists on disk. + if _, err := os.Stat(info.Path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("path after ForceDestroy stat err = %v, want not exist", err) + } + + // (b) Worktree is deregistered from git worktree list. + records, err := ws.listRecords(ctx, repo) + if err != nil { + t.Fatalf("listRecords after ForceDestroy: %v", err) + } + if _, ok := findWorktree(records, info.Path); ok { + t.Fatalf("worktree %q still registered after ForceDestroy", info.Path) + } +} + +// TestForceDestroyArgs verifies the new force arg builder emits --force +// and leaves worktreeRemoveArgs byte-for-byte unchanged (review item RA guard). +func TestForceDestroyArgs(t *testing.T) { + repo := "/repo" + path := "/managed/proj/sess" + + safe := worktreeRemoveArgs(repo, path) + for _, a := range safe { + if a == "--force" || a == "-f" { + t.Fatalf("worktreeRemoveArgs contains --force: %v", safe) + } + } + + forced := worktreeForceRemoveArgs(repo, path) + hasForce := false + for _, a := range forced { + if a == "--force" { + hasForce = true + } + } + if !hasForce { + t.Fatalf("worktreeForceRemoveArgs missing --force: %v", forced) + } +} diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index b985046b..0dbf9a84 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -63,6 +63,7 @@ func (s *stubWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error { func (s *stubWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { return s.Create(ctx, cfg) } +func (s *stubWorkspace) ForceDestroy(context.Context, ports.WorkspaceInfo) error { return nil } type captureMessenger struct{ msgs []string } diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 97683dc5..0551fd53 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -121,6 +121,11 @@ type Workspace interface { Create(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) Destroy(ctx context.Context, info WorkspaceInfo) error Restore(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) + // ForceDestroy removes the worktree unconditionally, bypassing the + // dirty-worktree refusal that Destroy enforces. It is only safe to call + // AFTER the session's uncommitted work has been captured (Task 2's + // StashUncommitted). Never call it from interactive teardown paths. + ForceDestroy(ctx context.Context, info WorkspaceInfo) error } // Workspace-level sentinels surfaced through Create/Restore/Destroy so callers diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index b7f3dcca..f04e8d37 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -213,6 +213,7 @@ func (w *fakeWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error { func (w *fakeWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { return w.Create(ctx, cfg) } +func (w *fakeWorkspace) ForceDestroy(context.Context, ports.WorkspaceInfo) error { return nil } type fakeMessenger struct{ msgs []string } From cbe6f21d95e1823a5559e346bb45e8415c63be37 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 05:31:46 +0530 Subject: [PATCH 28/60] feat(workspace): add StashUncommitted and ApplyPreserved for session lifecycle Implements the correctness-critical save-on-close / restore-on-open pair in the gitworktree adapter: - StashUncommitted: captures uncommitted work (tracked edits and new non-ignored files) via a temp GIT_INDEX_FILE into a real commit stored at refs/ao/preserved/. Never touches the real index or stash stack. Returns empty string for clean worktrees. Logs the count of .gitignore-skipped paths. - ApplyPreserved: replays the preserve commit onto a freshly re-added worktree via "git checkout -- .". Deletes the ref on clean success; keeps it and returns ErrPreservedConflict (wrapped) on content conflicts. - Adds both methods to ports.Workspace interface and stubs them in integration and session_manager test doubles. TDD: wrote two failing tests first (RED confirmed via build failure on undefined methods), then implemented to GREEN. All 39 adapter tests pass. Co-Authored-By: Claude Opus 4.8 --- .../workspace/gitworktree/commands.go | 54 +++++ .../workspace/gitworktree/workspace.go | 190 ++++++++++++++++++ .../gitworktree/workspace_preserve_test.go | 154 ++++++++++++++ .../integration/lifecycle_sqlite_test.go | 6 + backend/internal/ports/outbound.go | 17 +- .../internal/session_manager/manager_test.go | 6 + 6 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 backend/internal/adapters/workspace/gitworktree/workspace_preserve_test.go diff --git a/backend/internal/adapters/workspace/gitworktree/commands.go b/backend/internal/adapters/workspace/gitworktree/commands.go index 4506e3a7..a9b54b43 100644 --- a/backend/internal/adapters/workspace/gitworktree/commands.go +++ b/backend/internal/adapters/workspace/gitworktree/commands.go @@ -50,6 +50,60 @@ func worktreeListPorcelainArgs(repo string) []string { return []string{"-C", repo, "worktree", "list", "--porcelain"} } +// addAllTempIndexArgs stages all tracked and non-ignored untracked files into a +// temp index file without touching the real index or the working tree. +// GIT_INDEX_FILE must be set in the command's environment before calling. +func addAllTempIndexArgs(worktree string) []string { + return []string{"-C", worktree, "add", "-A"} +} + +// writeTreeArgs flushes the temp index into a tree object and prints the SHA. +// GIT_INDEX_FILE must be set in the command's environment. +func writeTreeArgs(worktree string) []string { + return []string{"-C", worktree, "write-tree"} +} + +// commitTreeArgs creates a commit object from a tree SHA. parent is the HEAD +// SHA to set as parent; message is the commit message. When parent is empty +// (unborn HEAD), the -p flag is omitted. +func commitTreeArgs(worktree, treeSHA, parent, message string) []string { + args := []string{"-C", worktree, "commit-tree", treeSHA} + if parent != "" { + args = append(args, "-p", parent) + } + args = append(args, "-m", message) + return args +} + +// updateRefArgs creates or moves a ref to point at a commit SHA. +func updateRefArgs(worktree, ref, commitSHA string) []string { + return []string{"-C", worktree, "update-ref", ref, commitSHA} +} + +// deleteRefArgs deletes a ref unconditionally. +func deleteRefArgs(worktree, ref string) []string { + return []string{"-C", worktree, "update-ref", "-d", ref} +} + +// revParseHeadArgs returns the HEAD commit SHA in the worktree. +// Exit code 128 means the repo has no commits (unborn HEAD). +func revParseHeadArgs(worktree string) []string { + return []string{"-C", worktree, "rev-parse", "--verify", "HEAD"} +} + +// checkoutTreeArgs restores all files from a commit's tree onto the working +// tree without switching HEAD. This is the apply step in ApplyPreserved: it +// reproduces tracked-file edits and new untracked files captured by +// StashUncommitted, leaving conflict markers on content conflicts. +func checkoutTreeArgs(worktree, commitSHA string) []string { + return []string{"-C", worktree, "checkout", commitSHA, "--", "."} +} + +// ignoredCountArgs lists files skipped because of .gitignore (dry-run, no mutation). +func ignoredCountArgs(worktree string) []string { + return []string{"-C", worktree, "status", "--ignored", "--porcelain"} +} + func baseRefCandidates(branch, defaultBranch string) []string { candidates := []string{"origin/" + branch} if strings.Contains(defaultBranch, "/") { diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index e7352c9b..bc3cd143 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "os/exec" "path/filepath" @@ -26,6 +27,12 @@ var ( ErrUnsafePath = errors.New("gitworktree: unsafe workspace path") ) +// ErrPreservedConflict is returned by ApplyPreserved when the apply produces +// merge conflicts. The preserve ref is kept intact so the caller can surface +// the conflict to the user and retry. The working tree is left with conflict +// markers for manual resolution. +var ErrPreservedConflict = errors.New("gitworktree: preserved apply produced conflicts") + // ErrBranchCheckedOutElsewhere and ErrBranchNotFetched are adapter-local aliases // of the port-level sentinels: they preserve the gitworktree-prefixed message // while letting the service layer match on ports.ErrWorkspaceBranchCheckedOutElsewhere @@ -223,6 +230,189 @@ func (w *Workspace) ForceDestroy(ctx context.Context, info ports.WorkspaceInfo) return nil } +// StashUncommitted captures all uncommitted work in the session's worktree +// into a git commit object WITHOUT mutating the working tree or the global +// stash stack. The commit is stored at refs/ao/preserved/. +// +// It builds the preserve commit through a temporary index file so tracked +// edits AND new non-ignored files are captured while .gitignore-d files are +// silently skipped (honoured because we never pass -f/--force to git-add). +// +// Returns the full ref name (e.g. "refs/ao/preserved/sess-1"). Returns an +// empty string (and no error) if the worktree is clean. +func (w *Workspace) StashUncommitted(ctx context.Context, info ports.WorkspaceInfo) (string, error) { + if info.Path == "" { + return "", fmt.Errorf("%w: empty path", ErrUnsafePath) + } + if info.SessionID == "" { + return "", errors.New("gitworktree: session id is required for StashUncommitted") + } + + // Early exit for clean worktrees: nothing to preserve. + dirty, err := w.isDirty(ctx, info.Path) + if err != nil { + return "", fmt.Errorf("gitworktree: StashUncommitted dirty check: %w", err) + } + if !dirty { + return "", nil + } + + // Log the count of ignored paths that will be skipped. + if skipCount, err := w.countIgnoredPaths(ctx, info.Path); err == nil { + slog.InfoContext(ctx, "gitworktree: StashUncommitted skipping ignored paths", + "session", string(info.SessionID), + "skipped_count", skipCount, + ) + } + + // Reserve a unique path for the temp index in the system temp dir (not ~/.ao). + // We must NOT pre-create the file: git requires GIT_INDEX_FILE to either not + // exist (it creates it) or be a valid git index. os.CreateTemp gives us a + // unique name; we close and remove it immediately so git gets an absent path. + tmpIdx, err := os.CreateTemp("", "ao-preserve-idx-*") + if err != nil { + return "", fmt.Errorf("gitworktree: reserve temp index path: %w", err) + } + tmpIdxPath := tmpIdx.Name() + tmpIdx.Close() + // Remove now so git sees an absent path (not a 0-byte corrupt index). + _ = os.Remove(tmpIdxPath) + // Deferred remove is a best-effort cleanup in case git leaves the file. + defer os.Remove(tmpIdxPath) + + // Stage all tracked and non-ignored untracked files into the temp index. + // GIT_INDEX_FILE overrides the index so the real index is never touched. + addCmd := exec.CommandContext(ctx, w.binary, addAllTempIndexArgs(info.Path)...) + addCmd.Env = append(os.Environ(), "GIT_INDEX_FILE="+tmpIdxPath) + if out, err := addCmd.CombinedOutput(); err != nil { + return "", commandError{args: append([]string{w.binary}, addAllTempIndexArgs(info.Path)...), output: string(out), err: err} + } + + // Write the staged tree to get a tree SHA. + writeTreeCmd := exec.CommandContext(ctx, w.binary, writeTreeArgs(info.Path)...) + writeTreeCmd.Env = append(os.Environ(), "GIT_INDEX_FILE="+tmpIdxPath) + treeOut, err := writeTreeCmd.CombinedOutput() + if err != nil { + return "", commandError{args: append([]string{w.binary}, writeTreeArgs(info.Path)...), output: string(treeOut), err: err} + } + treeSHA := strings.TrimSpace(string(treeOut)) + + // Resolve HEAD. An unborn HEAD (no commits yet) means we omit the -p flag + // from commit-tree so the preserve commit has no parent. + headOut, headErr := w.run(ctx, w.binary, revParseHeadArgs(info.Path)...) + headSHA := "" + if headErr == nil { + headSHA = strings.TrimSpace(string(headOut)) + } + // headErr != nil means unborn HEAD: headSHA stays empty, commit-tree gets no -p. + + // If the preserve tree SHA equals HEAD's tree SHA the working tree is + // effectively clean from git's perspective (only ignored files differ). + if headSHA != "" { + headTreeOut, err := w.run(ctx, w.binary, "-C", info.Path, "rev-parse", headSHA+"^{tree}") + if err == nil { + headTreeSHA := strings.TrimSpace(string(headTreeOut)) + if headTreeSHA == treeSHA { + // Nothing to preserve beyond ignored files. + return "", nil + } + } + } + + // Create a commit object that wraps the preserve tree. + msg := "ao preserved " + string(info.SessionID) + commitOut, err := w.run(ctx, w.binary, commitTreeArgs(info.Path, treeSHA, headSHA, msg)...) + if err != nil { + return "", fmt.Errorf("gitworktree: commit-tree: %w", err) + } + commitSHA := strings.TrimSpace(string(commitOut)) + + // Point the preserve ref at the commit. + ref := "refs/ao/preserved/" + string(info.SessionID) + if _, err := w.run(ctx, w.binary, updateRefArgs(info.Path, ref, commitSHA)...); err != nil { + return "", fmt.Errorf("gitworktree: update-ref %q: %w", ref, err) + } + return ref, nil +} + +// countIgnoredPaths returns the number of entries listed by +// "git status --ignored --porcelain" that start with "!!" (ignored). +func (w *Workspace) countIgnoredPaths(ctx context.Context, worktree string) (int, error) { + out, err := w.run(ctx, w.binary, ignoredCountArgs(worktree)...) + if err != nil { + return 0, fmt.Errorf("gitworktree: count ignored: %w", err) + } + count := 0 + for _, line := range strings.Split(string(out), "\n") { + if strings.HasPrefix(line, "!! ") { + count++ + } + } + return count, nil +} + +// ApplyPreserved replays the capture created by StashUncommitted onto the +// (freshly re-added) worktree. On clean success, the preserve ref is deleted. +// On conflict, the ref is kept, conflict markers are left in place, and +// ErrPreservedConflict (wrapped) is returned so the caller can surface it. +// +// NEVER deletes the preserve ref on a failed or conflicted apply. +func (w *Workspace) ApplyPreserved(ctx context.Context, info ports.WorkspaceInfo, ref string) error { + if info.Path == "" { + return fmt.Errorf("%w: empty path", ErrUnsafePath) + } + if ref == "" { + return errors.New("gitworktree: ApplyPreserved: ref must not be empty") + } + + // Resolve the ref to its commit SHA. + resolveOut, err := w.run(ctx, w.binary, revParseVerifyArgs(info.Path, ref)...) + if err != nil { + return fmt.Errorf("gitworktree: ApplyPreserved resolve ref %q: %w", ref, err) + } + commitSHA := strings.TrimSpace(string(resolveOut)) + + // Apply all files from the preserve commit's tree onto the working tree via + // "git checkout -- .". This restores tracked-file edits and new files + // that were captured by StashUncommitted. On a content conflict git writes + // conflict markers and exits non-zero. + applyErr := w.runCheckoutTree(ctx, info.Path, commitSHA) + if applyErr != nil { + var cmdErr commandError + if errors.As(applyErr, &cmdErr) { + out := strings.ToLower(cmdErr.output) + if strings.Contains(out, "conflict") { + return fmt.Errorf("%w: %w", ErrPreservedConflict, applyErr) + } + } + return fmt.Errorf("gitworktree: ApplyPreserved checkout %q: %w", ref, applyErr) + } + + // Clean apply: remove the preserve ref so it is never replayed twice. + if _, err := w.run(ctx, w.binary, deleteRefArgs(info.Path, ref)...); err != nil { + // Log but do not fail: the work is already applied. A dangling preserve + // ref is harmless; the next StashUncommitted will overwrite it. + slog.WarnContext(ctx, "gitworktree: ApplyPreserved could not delete preserve ref", + "ref", ref, + "err", err, + ) + } + return nil +} + +// runCheckoutTree runs "git checkout -- ." against the worktree and +// captures combined output so conflict messages are available in the returned +// commandError. +func (w *Workspace) runCheckoutTree(ctx context.Context, worktree, commitSHA string) error { + args := checkoutTreeArgs(worktree, commitSHA) + cmd := exec.CommandContext(ctx, w.binary, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return commandError{args: append([]string{w.binary}, args...), output: string(out), err: err} + } + return nil +} + // Restore re-attaches to an existing worktree for the session if one is still // present, recreating the handle without disturbing its contents. func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_preserve_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_preserve_test.go new file mode 100644 index 00000000..58364a32 --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/workspace_preserve_test.go @@ -0,0 +1,154 @@ +package gitworktree + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// TestWorkspaceIntegrationStashApplyRoundTrip is the primary correctness test +// for the save-on-close / restore-on-open lifecycle: +// +// 1. Create a worktree with a tracked-file edit, a new non-ignored file, +// and a file covered by .gitignore. +// 2. StashUncommitted: assert the returned ref is non-empty. +// 3. ForceDestroy: remove the worktree unconditionally. +// 4. Re-add the worktree via Restore (simulating the re-open path). +// 5. ApplyPreserved: replay the captured state. +// 6. Assert that the tracked edit and the new non-ignored file reappear, +// and the .gitignore-matched file does NOT reappear. +func TestWorkspaceIntegrationStashApplyRoundTrip(t *testing.T) { + git := requireGit(t) + tmp := t.TempDir() + repo := setupOriginClone(t, git, tmp) + root := filepath.Join(tmp, "managed") + ws, err := New(Options{Binary: git, ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + ctx := context.Background() + cfg := ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess-preserve", Branch: "feature/preserve"} + + info, err := ws.Create(ctx, cfg) + if err != nil { + t.Fatalf("create: %v", err) + } + + // Stage 1: create a .gitignore that covers a secret file. + if err := os.WriteFile(filepath.Join(info.Path, ".gitignore"), []byte("secret.txt\n"), 0o644); err != nil { + t.Fatalf("write .gitignore: %v", err) + } + runGit(t, git, info.Path, "add", ".gitignore") + runGit(t, git, info.Path, "commit", "-m", "add gitignore") + + // Stage 2: create uncommitted work: + // - tracked-file edit: modify README.md (already committed from seed) + if err := os.WriteFile(filepath.Join(info.Path, "README.md"), []byte("edited by agent\n"), 0o644); err != nil { + t.Fatalf("write README: %v", err) + } + // - new non-ignored file: should be captured + if err := os.WriteFile(filepath.Join(info.Path, "agent-work.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatalf("write agent-work.go: %v", err) + } + // - ignored file: must NOT be captured + if err := os.WriteFile(filepath.Join(info.Path, "secret.txt"), []byte("super-secret\n"), 0o644); err != nil { + t.Fatalf("write secret.txt: %v", err) + } + + // StashUncommitted: must return a non-empty ref. + ref, err := ws.StashUncommitted(ctx, info) + if err != nil { + t.Fatalf("StashUncommitted: %v", err) + } + if ref == "" { + t.Fatal("StashUncommitted returned empty ref for dirty worktree") + } + if !strings.HasPrefix(ref, "refs/ao/preserved/") { + t.Fatalf("ref = %q, want refs/ao/preserved/... prefix", ref) + } + + // ForceDestroy: simulate session close. + if err := ws.ForceDestroy(ctx, info); err != nil { + t.Fatalf("ForceDestroy: %v", err) + } + if _, err := os.Stat(info.Path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("worktree path still exists after ForceDestroy") + } + + // Restore: simulate re-open / re-attach. + restored, err := ws.Restore(ctx, cfg) + if err != nil { + t.Fatalf("Restore: %v", err) + } + if restored.Path != info.Path { + t.Fatalf("restored path = %q, want %q", restored.Path, info.Path) + } + + // ApplyPreserved: replay the captured state. + if err := ws.ApplyPreserved(ctx, restored, ref); err != nil { + t.Fatalf("ApplyPreserved: %v", err) + } + + // Tracked edit must reappear. + readmeBytes, err := os.ReadFile(filepath.Join(restored.Path, "README.md")) + if err != nil { + t.Fatalf("read README after apply: %v", err) + } + if string(readmeBytes) != "edited by agent\n" { + t.Fatalf("README content = %q, want %q", string(readmeBytes), "edited by agent\n") + } + + // New non-ignored file must reappear. + if _, err := os.Stat(filepath.Join(restored.Path, "agent-work.go")); err != nil { + t.Fatalf("agent-work.go missing after apply: %v", err) + } + + // Ignored file must NOT reappear. + if _, err := os.Stat(filepath.Join(restored.Path, "secret.txt")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("secret.txt exists after apply but must not (it was .gitignore-d)") + } + + // After a successful apply the ref must be deleted. + checkRefArgs := revParseVerifyArgs(repo, ref) + if out, err := ws.run(ctx, ws.binary, checkRefArgs...); err == nil { + t.Fatalf("preserve ref %q still exists after successful ApplyPreserved (points to %s)", ref, strings.TrimSpace(string(out))) + } +} + +// TestWorkspaceIntegrationStashCleanWorktree proves that StashUncommitted on a +// clean worktree returns an empty ref and no error (nothing to preserve). +func TestWorkspaceIntegrationStashCleanWorktree(t *testing.T) { + git := requireGit(t) + tmp := t.TempDir() + repo := setupOriginClone(t, git, tmp) + root := filepath.Join(tmp, "managed") + ws, err := New(Options{Binary: git, ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + ctx := context.Background() + cfg := ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess-clean", Branch: "feature/clean-stash"} + + info, err := ws.Create(ctx, cfg) + if err != nil { + t.Fatalf("create: %v", err) + } + + ref, err := ws.StashUncommitted(ctx, info) + if err != nil { + t.Fatalf("StashUncommitted on clean worktree: %v", err) + } + if ref != "" { + t.Fatalf("StashUncommitted on clean worktree returned non-empty ref %q, want empty", ref) + } + + // Cleanup. + if err := ws.Destroy(ctx, info); err != nil { + t.Fatalf("destroy clean worktree: %v", err) + } +} diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index 0dbf9a84..0add8350 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -64,6 +64,12 @@ func (s *stubWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) return s.Create(ctx, cfg) } func (s *stubWorkspace) ForceDestroy(context.Context, ports.WorkspaceInfo) error { return nil } +func (s *stubWorkspace) StashUncommitted(_ context.Context, _ ports.WorkspaceInfo) (string, error) { + return "", nil +} +func (s *stubWorkspace) ApplyPreserved(_ context.Context, _ ports.WorkspaceInfo, _ string) error { + return nil +} type captureMessenger struct{ msgs []string } diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 0551fd53..d1552b83 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -123,9 +123,22 @@ type Workspace interface { Restore(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) // ForceDestroy removes the worktree unconditionally, bypassing the // dirty-worktree refusal that Destroy enforces. It is only safe to call - // AFTER the session's uncommitted work has been captured (Task 2's - // StashUncommitted). Never call it from interactive teardown paths. + // AFTER the session's uncommitted work has been captured via StashUncommitted. + // Never call it from interactive teardown paths. ForceDestroy(ctx context.Context, info WorkspaceInfo) error + // StashUncommitted captures all uncommitted work in the worktree as a git + // commit object stored at refs/ao/preserved/, WITHOUT mutating + // the working tree or the global stash stack. Tracked edits and new + // non-ignored files are captured; .gitignore-d files are skipped (the count + // of skipped ignored paths is logged). Returns the ref name on success, or + // an empty string if the worktree is clean (nothing to preserve). + StashUncommitted(ctx context.Context, info WorkspaceInfo) (ref string, err error) + // ApplyPreserved replays a capture created by StashUncommitted onto the + // worktree identified by info. On clean success the preserve ref is deleted. + // On conflict, the ref is kept, conflict markers are left in the working + // tree, and ErrPreservedConflict (wrapped) is returned. The ref must never + // be deleted on a failed or conflicted apply. + ApplyPreserved(ctx context.Context, info WorkspaceInfo, ref string) error } // Workspace-level sentinels surfaced through Create/Restore/Destroy so callers diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index f04e8d37..ac32a94d 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -214,6 +214,12 @@ func (w *fakeWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) return w.Create(ctx, cfg) } func (w *fakeWorkspace) ForceDestroy(context.Context, ports.WorkspaceInfo) error { return nil } +func (w *fakeWorkspace) StashUncommitted(_ context.Context, _ ports.WorkspaceInfo) (string, error) { + return "", nil +} +func (w *fakeWorkspace) ApplyPreserved(_ context.Context, _ ports.WorkspaceInfo, _ string) error { + return nil +} type fakeMessenger struct{ msgs []string } From 243f4aadc11146accf2aab64e38ff85f430762ca Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 05:40:40 +0530 Subject: [PATCH 29/60] fix(workspace): replace path-checkout with cherry-pick in ApplyPreserved git checkout -- . is a path-checkout that always exits 0 for content divergence, making ErrPreservedConflict unreachable. Replace with git cherry-pick --no-commit which performs a true three-way merge, leaves textual conflict markers on conflict, and exits non-zero so the sentinel is correctly returned. Conflict detection now uses exit code only (locale-independent). Add TestWorkspaceIntegrationApplyPreservedConflict to assert: error is ErrPreservedConflict, preserve ref is kept, conflict markers appear in the file. All 40 tests pass. Co-Authored-By: Claude Opus 4.8 --- .superpowers/sdd/task-2-report.md | 205 ++++++++++++------ .../workspace/gitworktree/commands.go | 15 +- .../workspace/gitworktree/workspace.go | 40 ++-- .../gitworktree/workspace_preserve_test.go | 98 +++++++++ 4 files changed, 260 insertions(+), 98 deletions(-) diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md index 2a841763..efd38b62 100644 --- a/.superpowers/sdd/task-2-report.md +++ b/.superpowers/sdd/task-2-report.md @@ -1,95 +1,158 @@ -# Task 2 Report: runtime selection + daemon wiring + doctor/spawn hints +# Task 2 Report: StashUncommitted + ApplyPreserved ## Status: DONE -## Files Changed +## What was implemented -### New file -- `backend/internal/adapters/runtime/runtimeselect/runtimeselect.go` - - Defines the `Runtime` union interface (embeds `ports.Runtime`; adds `SendMessage`, `GetOutput`, `AttachCommand`). - - `New(log *slog.Logger) Runtime` returns `tmux.New(tmux.Options{})` on non-Windows, and replicates the old daemon zellij socket-dir setup on Windows. - - Compile-time assertions: `var _ Runtime = (*tmux.Runtime)(nil)` and `var _ Runtime = (*zellij.Runtime)(nil)`. - -### Modified files - -**`backend/internal/daemon/daemon.go`** -- Swapped `zellij` import for `runtimeselect`. -- Replaced the 10-line zellij socket-dir block with `runtimeAdapter := runtimeselect.New(log)`. -- Updated the terminal-streaming comment to be runtime-neutral. -- `os` import retained (still used by `newLogger()` for `os.Stderr`). - -**`backend/internal/daemon/lifecycle_wiring.go`** -- Swapped `zellij` import for `runtimeselect`. -- Changed `startSession` param from `runtime *zellij.Runtime` to `runtime runtimeselect.Runtime`. -- Updated doc comment from "over the real zellij runtime" to "over the selected runtime". - -**`backend/internal/cli/doctor.go`** -- Added `"runtime"` stdlib import. -- Renamed call site from `c.checkZellij(ctx)` to `c.checkTerminalRuntime(ctx)`. -- Added `checkTerminalRuntime` dispatcher: calls `checkTmux` on non-Windows, `checkZellij` on Windows. -- Added `checkTmux`: resolves `tmux` via `LookPath`, runs `tmux -V`, reports PASS with version string; WARN for missing (matching the zellij missing-level so end-to-end `ok: true` tests remain green), FAIL if found but version command errors. -- Kept `checkZellij` intact (Windows still uses it). - -**`backend/internal/cli/spawn.go`** -- Added `"runtime"` stdlib import and `tmux` adapter import. -- Replaced unconditional `zellij attach` hint with a runtime-aware block: - - Non-Windows: `tmux attach -t ` using `tmux.SessionName(res.Session.ID)`. - - Windows: existing `ZELLIJ_SOCKET_DIR=... zellij attach ` hint (unchanged). - -**`backend/internal/daemon/wiring_test.go`** -- Added `runtimeselect` import. -- `TestWiring_StartSessionBuildsSessionService`: replaced `zellij.New(zellij.Options{})` with `runtimeselect.New(nil)` so the test compiles and passes after the `startSession` signature change. -- `TestWiring_StartLifecycleThreadsMessengerIntoLCM` and `TestDaemonZellijSocketDir_LeavesBudgetForSessionNames` still use `zellij` directly; those sub-tests are testing zellij semantics that still exist and were not changed. - -**`backend/internal/cli/doctor_test.go`** -- Renamed and rewrote the three zellij-specific tool tests to cover `checkTmux` (on Darwin): - - `TestDoctorChecksZellijVersion` -> `TestDoctorChecksTmuxVersion` - - `TestDoctorFailsUnsupportedZellijVersion` -> `TestDoctorChecksTmuxVersionFailsOnError` - - `TestDoctorWarnsWhenZellijMissing` -> `TestDoctorWarnsWhenTmuxMissing` - -## Union Interface - -```go -type Runtime interface { - ports.Runtime // Create, Destroy, IsAlive - SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error - GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) - AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) -} -``` +Two methods on the `gitworktree.Workspace` adapter, plus corresponding interface and test-double updates. + +### StashUncommitted + +Location: `backend/internal/adapters/workspace/gitworktree/workspace.go` + +Algorithm: +1. `isDirty` early-exit for clean worktrees (returns `""`, nil). +2. Counts ignored-path skips via `git status --ignored --porcelain` and logs via `slog.InfoContext`. +3. Reserves a unique path via `os.CreateTemp` then immediately removes the 0-byte file so git sees an absent path (not a corrupt index). Deferred `os.Remove` cleans up after git writes it. +4. `GIT_INDEX_FILE= git -C add -A` stages tracked edits and new non-ignored files, skipping .gitignore entries (no -f/--force). +5. `GIT_INDEX_FILE= git -C write-tree` yields a tree SHA. +6. Compares tree SHA against `HEAD^{tree}`. If equal, only ignored files differ and nothing to preserve: returns `""`, nil. +7. `git -C commit-tree [-p ] -m "ao preserved "` (omits -p on unborn HEAD). +8. `git -C update-ref refs/ao/preserved/ ` and returns the ref name. -## SessionName export +### ApplyPreserved -`tmux.SessionName(id string) string` was already exported in Task 1 (line 267 of `tmux.go`). No change needed. +1. Resolves the ref to a commit SHA via `rev-parse --verify`. +2. Runs `git checkout -- .` which restores all files from the preserve tree onto the working tree without switching HEAD. Correctly reproduces tracked-file edits AND new untracked files that were captured by StashUncommitted. +3. On conflict (non-zero exit with "conflict" in output): returns `fmt.Errorf("%w: %w", ErrPreservedConflict, applyErr)`. Ref is NOT deleted. +4. On clean success: `git update-ref -d refs/ao/preserved/`. Warn-logs if ref deletion fails (does not fail the call). -## Handling of wiring_test.go +### Supporting additions -The only test that called `startSession` with a concrete `*zellij.Runtime` was `TestWiring_StartSessionBuildsSessionService`. It now passes `runtimeselect.New(nil)` (which returns the tmux adapter on Darwin). Tests that still use `zellij.New` directly (`startLifecycle` call and `DefaultSocketDir` assertion) were left untouched; they test zellij semantics and compile fine because the zellij import remains. +- `commands.go`: added `addAllTempIndexArgs`, `writeTreeArgs`, `commitTreeArgs`, `updateRefArgs`, `deleteRefArgs`, `revParseHeadArgs`, `checkoutTreeArgs`, `ignoredCountArgs`. +- `ports/outbound.go`: added `StashUncommitted` and `ApplyPreserved` to the `Workspace` interface. +- `ErrPreservedConflict` exported sentinel in `workspace.go`. +- Stub implementations added to `integration/lifecycle_sqlite_test.go` and `session_manager/manager_test.go`. -## Verification Outputs +## TDD Evidence -All run from `backend/`. +### RED (tests written first, before any implementation) -### `go build ./...` ``` -Go build: Success +$ go test ./internal/adapters/workspace/gitworktree/... +gitworktree [build failed] + workspace_preserve_test.go:64:17: ws.StashUncommitted undefined (type *Workspace...) + workspace_preserve_test.go:93:15: ws.ApplyPreserved undefined (type *Workspace...) + workspace_preserve_test.go:142:17: ws.StashUncommitted undefined (type *Workspace...) ``` -### `GOOS=windows go build ./...` +Tests failed because the methods did not exist yet. Confirmed they tested the right thing, not pre-existing behavior. + +### GREEN (after implementation) + ``` -Go build: Success +$ go test ./internal/adapters/workspace/gitworktree/... -v +Go test: 39 passed in 1 packages ``` -### `go test ./...` +All 39 tests pass, including: +- `TestWorkspaceIntegrationStashApplyRoundTrip`: full round-trip with tracked edit + new non-ignored file + .gitignore-d file. Asserts ref is non-empty, README edit reappears, agent-work.go reappears, secret.txt does NOT reappear, and ref is deleted after successful apply. +- `TestWorkspaceIntegrationStashCleanWorktree`: clean worktree returns empty ref. +- All 37 pre-existing tests unchanged. + +### Build and vet + +``` +$ go build ./... # Build: Success +$ go vet ./... # Vet: No issues found +``` + +## Files Changed + +- `backend/internal/adapters/workspace/gitworktree/workspace.go` (added StashUncommitted, ApplyPreserved, runCheckoutTree, countIgnoredPaths, ErrPreservedConflict, log/slog import) +- `backend/internal/adapters/workspace/gitworktree/commands.go` (added 8 new arg builders) +- `backend/internal/adapters/workspace/gitworktree/workspace_preserve_test.go` (new, 2 integration tests) +- `backend/internal/ports/outbound.go` (added 2 methods to Workspace interface) +- `backend/internal/integration/lifecycle_sqlite_test.go` (no-op stubs for new interface methods) +- `backend/internal/session_manager/manager_test.go` (no-op stubs for new interface methods) + +## Commit + +`cbe6f21 feat(workspace): add StashUncommitted and ApplyPreserved for session lifecycle` + +## Self-review checklist + +- [x] Preserve ref is exactly `refs/ao/preserved/` +- [x] `.gitignore` respected: no `-f`/`--force` on `git add`, ignored count logged +- [x] Temp index file does NOT pre-exist when git writes it (0-byte file removed before git-add) +- [x] Working tree and global stash stack are never mutated during StashUncommitted +- [x] Clean worktree returns `""` and no error (two guards: isDirty + tree-SHA comparison) +- [x] Unborn HEAD handled: headSHA stays empty, commitTreeArgs omits `-p` +- [x] Ref deleted only after a confirmed clean apply +- [x] Ref kept on conflict; ErrPreservedConflict returned wrapped +- [x] No em dashes or en dashes anywhere in code, comments, or messages +- [x] App state stays under ~/.ao (temp index in os.TempDir, preserve refs in project .git) +- [x] All 39 tests pass, go vet clean, go build clean + +## Concerns + +One mild concern: `git checkout -- .` also updates the staging area (index) in the re-added worktree, not just the working tree. This means after ApplyPreserved the restored files appear staged. For the session-lifecycle use case (agent resumes work) this is harmless and arguably desirable (the agent can commit immediately). If a future caller wants the files to appear as unstaged modifications, a `git reset HEAD` afterward would be needed. Current behavior matches the task requirements. + +--- + +## Review Fix: Replace checkout with cherry-pick for correct conflict detection + +### Status: DONE (review findings resolved) + +### Problem found in review + +`ApplyPreserved` used `git checkout -- .`, which is a path-checkout (not a merge). Path-checkout unconditionally overwrites the working tree and always exits 0 for content divergence. This made the `if applyErr != nil` conflict-handling block unreachable dead code and left the spec requirement unmet: the `ErrPreservedConflict` sentinel could never be returned. + +### New mechanism: cherry-pick --no-commit + +Replaced with `git cherry-pick --no-commit `. This computes the diff between the preserve commit and its parent (HEAD at save time) and performs a true three-way merge onto the current working tree. On conflict it leaves textual conflict markers (`<<<<<<<` etc.) in the affected files and exits non-zero WITHOUT committing or moving HEAD. Exit code alone drives conflict detection (locale-independent). New untracked files in the preserve tree come through as additions. + +The `--no-commit` (`-n`) flag leaves no sequencer state that would require a follow-up `cherry-pick --abort`. + +### Code changes + +- `commands.go`: removed `checkoutTreeArgs` (path-checkout), added `cherryPickNoCommitArgs` (three-way merge). +- `workspace.go`: replaced `runCheckoutTree` with `runCherryPickNoCommit`; replaced string-scan conflict detection (`strings.Contains(out, "conflict")`) with exit-code detection (any non-zero exit from the merge step returns `ErrPreservedConflict` immediately). + +### RED evidence + +Under the OLD mechanism (`git checkout -- .`), the new conflict test (`TestWorkspaceIntegrationApplyPreservedConflict`) would have failed because: + +1. `git checkout -- .` exits 0 even when file content diverges (path-checkout never writes conflict markers and never signals a conflict). +2. `applyErr` would always be nil, so `ErrPreservedConflict` would never be returned. +3. The assertion `errors.Is(applyErr, ErrPreservedConflict)` would fail with "ApplyPreserved returned nil, want ErrPreservedConflict". + +### GREEN evidence + ``` -Go test: 1585 passed in 75 packages +$ /opt/homebrew/bin/go test -v ./internal/adapters/workspace/gitworktree/ \ + -run "TestWorkspaceIntegrationStashApplyRoundTrip|TestWorkspaceIntegrationApplyPreservedConflict" \ + -count=1 + +=== RUN TestWorkspaceIntegrationStashApplyRoundTrip +2026/06/24 05:39:57 INFO gitworktree: StashUncommitted skipping ignored paths session=sess-preserve skipped_count=1 +--- PASS: TestWorkspaceIntegrationStashApplyRoundTrip (0.47s) +=== RUN TestWorkspaceIntegrationApplyPreservedConflict +2026/06/24 05:39:57 INFO gitworktree: StashUncommitted skipping ignored paths session=sess-conflict skipped_count=0 +--- PASS: TestWorkspaceIntegrationApplyPreservedConflict (0.46s) +PASS +ok github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree 1.072s ``` -### `go vet ./internal/daemon/... ./internal/cli/... ./internal/adapters/runtime/...` +Full package: 40 tests pass (was 39 before this fix added the conflict test). + ``` -Go vet: No issues found +$ go build ./... # Build: Success +$ go vet ./internal/adapters/workspace/gitworktree/... # Vet: No issues found ``` -## Concerns +### Files changed in this review fix -None. All checks green, no pre-existing failures, no new dependencies, go.mod unchanged. +- `backend/internal/adapters/workspace/gitworktree/workspace.go` (ApplyPreserved + runCherryPickNoCommit replacing runCheckoutTree) +- `backend/internal/adapters/workspace/gitworktree/commands.go` (cherryPickNoCommitArgs replacing checkoutTreeArgs) +- `backend/internal/adapters/workspace/gitworktree/workspace_preserve_test.go` (added TestWorkspaceIntegrationApplyPreservedConflict) diff --git a/backend/internal/adapters/workspace/gitworktree/commands.go b/backend/internal/adapters/workspace/gitworktree/commands.go index a9b54b43..cc0339bf 100644 --- a/backend/internal/adapters/workspace/gitworktree/commands.go +++ b/backend/internal/adapters/workspace/gitworktree/commands.go @@ -91,12 +91,15 @@ func revParseHeadArgs(worktree string) []string { return []string{"-C", worktree, "rev-parse", "--verify", "HEAD"} } -// checkoutTreeArgs restores all files from a commit's tree onto the working -// tree without switching HEAD. This is the apply step in ApplyPreserved: it -// reproduces tracked-file edits and new untracked files captured by -// StashUncommitted, leaving conflict markers on content conflicts. -func checkoutTreeArgs(worktree, commitSHA string) []string { - return []string{"-C", worktree, "checkout", commitSHA, "--", "."} +// cherryPickNoCommitArgs applies a single commit's diff onto the current +// working tree via a true three-way merge without committing or moving HEAD. +// git cherry-pick --no-commit computes the diff between and its parent +// and 3-way-merges it onto the current working tree. On conflict it leaves +// textual conflict markers in the affected files and exits non-zero. New files +// added in the preserve commit come through as additions. Because -n is used, +// no sequencer state is left that would require a cherry-pick --quit afterward. +func cherryPickNoCommitArgs(worktree, commitSHA string) []string { + return []string{"-C", worktree, "cherry-pick", "--no-commit", commitSHA} } // ignoredCountArgs lists files skipped because of .gitignore (dry-run, no mutation). diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index bc3cd143..23cd4aef 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -352,9 +352,10 @@ func (w *Workspace) countIgnoredPaths(ctx context.Context, worktree string) (int } // ApplyPreserved replays the capture created by StashUncommitted onto the -// (freshly re-added) worktree. On clean success, the preserve ref is deleted. -// On conflict, the ref is kept, conflict markers are left in place, and -// ErrPreservedConflict (wrapped) is returned so the caller can surface it. +// (freshly re-added) worktree using a true three-way merge (cherry-pick --no-commit). +// On clean success, the preserve ref is deleted. +// On conflict, the ref is kept, conflict markers are left in the affected files, +// and ErrPreservedConflict (wrapped) is returned so the caller can surface it. // // NEVER deletes the preserve ref on a failed or conflicted apply. func (w *Workspace) ApplyPreserved(ctx context.Context, info ports.WorkspaceInfo, ref string) error { @@ -372,20 +373,17 @@ func (w *Workspace) ApplyPreserved(ctx context.Context, info ports.WorkspaceInfo } commitSHA := strings.TrimSpace(string(resolveOut)) - // Apply all files from the preserve commit's tree onto the working tree via - // "git checkout -- .". This restores tracked-file edits and new files - // that were captured by StashUncommitted. On a content conflict git writes - // conflict markers and exits non-zero. - applyErr := w.runCheckoutTree(ctx, info.Path, commitSHA) + // Apply the preserve commit via "git cherry-pick --no-commit ". + // cherry-pick computes the diff between the preserve commit and its parent + // (the HEAD at save time) and 3-way-merges it onto the current working tree. + // On conflict it leaves textual conflict markers in the affected files and + // exits non-zero WITHOUT committing or moving HEAD. Conflict detection uses + // the exit code only (not output text) to stay locale-independent. + applyErr := w.runCherryPickNoCommit(ctx, info.Path, commitSHA) if applyErr != nil { - var cmdErr commandError - if errors.As(applyErr, &cmdErr) { - out := strings.ToLower(cmdErr.output) - if strings.Contains(out, "conflict") { - return fmt.Errorf("%w: %w", ErrPreservedConflict, applyErr) - } - } - return fmt.Errorf("gitworktree: ApplyPreserved checkout %q: %w", ref, applyErr) + // Any non-zero exit from the merge step is a conflict: keep the ref, + // leave conflict markers in place, and surface the sentinel. + return fmt.Errorf("%w: %w", ErrPreservedConflict, applyErr) } // Clean apply: remove the preserve ref so it is never replayed twice. @@ -400,11 +398,11 @@ func (w *Workspace) ApplyPreserved(ctx context.Context, info ports.WorkspaceInfo return nil } -// runCheckoutTree runs "git checkout -- ." against the worktree and -// captures combined output so conflict messages are available in the returned -// commandError. -func (w *Workspace) runCheckoutTree(ctx context.Context, worktree, commitSHA string) error { - args := checkoutTreeArgs(worktree, commitSHA) +// runCherryPickNoCommit runs "git -C cherry-pick --no-commit " +// and captures combined output so any conflict details are available in the +// returned commandError. Exit code detection happens in the caller. +func (w *Workspace) runCherryPickNoCommit(ctx context.Context, worktree, commitSHA string) error { + args := cherryPickNoCommitArgs(worktree, commitSHA) cmd := exec.CommandContext(ctx, w.binary, args...) out, err := cmd.CombinedOutput() if err != nil { diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_preserve_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_preserve_test.go index 58364a32..faf2dc46 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace_preserve_test.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace_preserve_test.go @@ -120,6 +120,104 @@ func TestWorkspaceIntegrationStashApplyRoundTrip(t *testing.T) { } } +// TestWorkspaceIntegrationApplyPreservedConflict verifies the spec for a +// conflicting apply (plan edge case 5): +// +// 1. Set up a repo where the base HEAD has a tracked file with content "A". +// 2. StashUncommitted after editing the file to "B" (preserve commit: B over A). +// 3. After ForceDestroy and Restore, diverge the same file to content "C" +// (simulating a base-moved or independently-edited state). +// 4. ApplyPreserved must: +// (a) return an error that satisfies errors.Is(err, ErrPreservedConflict), +// (b) leave the preserve ref intact (NOT delete it), +// (c) leave textual conflict markers in the conflicting file. +func TestWorkspaceIntegrationApplyPreservedConflict(t *testing.T) { + git := requireGit(t) + tmp := t.TempDir() + repo := setupOriginClone(t, git, tmp) + root := filepath.Join(tmp, "managed") + ws, err := New(Options{Binary: git, ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + ctx := context.Background() + cfg := ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess-conflict", Branch: "feature/conflict-test"} + + info, err := ws.Create(ctx, cfg) + if err != nil { + t.Fatalf("create: %v", err) + } + + // Write base content "A" into a tracked file and commit it so it is the + // HEAD tree that StashUncommitted will use as the parent. + conflictFile := filepath.Join(info.Path, "shared.txt") + if err := os.WriteFile(conflictFile, []byte("A\n"), 0o644); err != nil { + t.Fatalf("write base A: %v", err) + } + runGit(t, git, info.Path, "add", "shared.txt") + runGit(t, git, info.Path, "commit", "-m", "base: A") + + // Edit to "B" without committing: this is what the agent had in flight. + if err := os.WriteFile(conflictFile, []byte("B\n"), 0o644); err != nil { + t.Fatalf("write B: %v", err) + } + + // Preserve: StashUncommitted captures B-over-A into a ref. + ref, err := ws.StashUncommitted(ctx, info) + if err != nil { + t.Fatalf("StashUncommitted: %v", err) + } + if ref == "" { + t.Fatal("StashUncommitted returned empty ref for dirty worktree") + } + + // Simulate session close. + if err := ws.ForceDestroy(ctx, info); err != nil { + t.Fatalf("ForceDestroy: %v", err) + } + + // Restore: re-add the worktree (re-open path). + restored, err := ws.Restore(ctx, cfg) + if err != nil { + t.Fatalf("Restore: %v", err) + } + + // Diverge the same file to content "C" in the restored worktree so that + // cherry-pick --no-commit will produce a real three-way conflict (A -> B + // from preserve vs A -> C in the current tree). + conflictFileRestored := filepath.Join(restored.Path, "shared.txt") + if err := os.WriteFile(conflictFileRestored, []byte("C\n"), 0o644); err != nil { + t.Fatalf("write C: %v", err) + } + // Stage the diverging edit so it is in the index; cherry-pick merges against + // the index, not just the working tree. + runGit(t, git, restored.Path, "add", "shared.txt") + + // ApplyPreserved must detect the conflict and return ErrPreservedConflict. + applyErr := ws.ApplyPreserved(ctx, restored, ref) + if applyErr == nil { + t.Fatal("ApplyPreserved returned nil, want ErrPreservedConflict") + } + if !errors.Is(applyErr, ErrPreservedConflict) { + t.Fatalf("ApplyPreserved error = %v, want errors.Is(..., ErrPreservedConflict)", applyErr) + } + + // (b) The preserve ref must still exist. + checkRefArgs := revParseVerifyArgs(repo, ref) + if _, err := ws.run(ctx, ws.binary, checkRefArgs...); err != nil { + t.Fatalf("preserve ref %q was deleted after a conflicting apply, must be kept: %v", ref, err) + } + + // (c) The conflicting file must contain textual conflict markers. + contents, err := os.ReadFile(conflictFileRestored) + if err != nil { + t.Fatalf("read conflicting file: %v", err) + } + if !strings.Contains(string(contents), "<<<<<<<") { + t.Fatalf("conflicting file has no conflict markers after ApplyPreserved conflict; content:\n%s", string(contents)) + } +} + // TestWorkspaceIntegrationStashCleanWorktree proves that StashUncommitted on a // clean worktree returns an empty ref and no error (nothing to preserve). func TestWorkspaceIntegrationStashCleanWorktree(t *testing.T) { From 1decfde42030de1ec75fe0ebe7189bb59ef3b79d Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 05:50:20 +0530 Subject: [PATCH 30/60] feat(session-manager): add SaveAndTeardownAll and RestoreAll for shutdown lifecycle Implements Task 3: capture-then-destroy on shutdown and restore-all on startup. - Adds ErrPreservedConflict to ports as a named sentinel; gitworktree aliases it (following the same pattern as ErrBranchCheckedOutElsewhere). - Extends the Store interface with UpsertSessionWorktree and ListSessionWorktrees so the session manager can write the shutdown-saved marker and read it back. - SaveAndTeardownAll: for every live session with a workspace path, stash uncommitted work, write the session_worktrees row (DB commit before worktree removal, crash-safety invariant), mark terminated, destroy runtime, force-remove the worktree. Best-effort per session; no kind filter. - RestoreAll: for every terminated session that has a session_worktrees row (the marker written by SaveAndTeardownAll), re-create the worktree, apply any preserved ref (conflict logs and continues), then relaunch via the existing single-session Restore. Sessions killed by the user before shutdown (no row) are skipped. Best-effort per session; no kind filter. - TDD: 9 new tests (RED confirmed via build failure, GREEN confirmed 63 pass). Full suite: 1621 tests across 77 packages. Co-Authored-By: Claude Opus 4.8 --- .../workspace/gitworktree/workspace.go | 9 +- backend/internal/ports/outbound.go | 6 + backend/internal/session_manager/manager.go | 167 ++++++++ .../internal/session_manager/manager_test.go | 389 +++++++++++++++++- 4 files changed, 556 insertions(+), 15 deletions(-) diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index 23cd4aef..84551fba 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -27,11 +27,10 @@ var ( ErrUnsafePath = errors.New("gitworktree: unsafe workspace path") ) -// ErrPreservedConflict is returned by ApplyPreserved when the apply produces -// merge conflicts. The preserve ref is kept intact so the caller can surface -// the conflict to the user and retry. The working tree is left with conflict -// markers for manual resolution. -var ErrPreservedConflict = errors.New("gitworktree: preserved apply produced conflicts") +// ErrPreservedConflict is an adapter-local alias of ports.ErrPreservedConflict. +// Tests inside this package use this name; callers outside use ports.ErrPreservedConflict +// and errors.Is works because the adapter wraps the ports sentinel. +var ErrPreservedConflict = ports.ErrPreservedConflict // ErrBranchCheckedOutElsewhere and ErrBranchNotFetched are adapter-local aliases // of the port-level sentinels: they preserve the gitworktree-prefixed message diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index d1552b83..60f2a980 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -158,6 +158,12 @@ var ( // it holds uncommitted changes or untracked files. Teardown is never // forced; callers treat the workspace as intentionally preserved. ErrWorkspaceDirty = errors.New("workspace: uncommitted changes present") + // ErrPreservedConflict is returned by ApplyPreserved when replaying a + // preserved ref onto the worktree produces merge conflicts. The ref is + // kept intact (never deleted on conflict); the working tree is left with + // conflict markers for manual resolution. Adapters wrap this sentinel via + // fmt.Errorf so callers can match it with errors.Is. + ErrPreservedConflict = errors.New("workspace: preserved apply produced conflicts") ) // WorkspaceConfig is the spec for creating or restoring a session's workspace. diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 104982bd..6ca496f6 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -77,6 +77,15 @@ type Store interface { // when the row had already progressed past seed state — preserving the // no-resurrection guarantee for live sessions. DeleteSession(ctx context.Context, id domain.SessionID) (bool, error) + // UpsertSessionWorktree records or updates the worktree row for a session. + // SaveAndTeardownAll writes the preserved_ref here (even when empty) as the + // "shutdown-saved" marker before ForceDestroying the worktree. + UpsertSessionWorktree(ctx context.Context, row domain.SessionWorktreeRecord) error + // ListSessionWorktrees returns every worktree row for a session. RestoreAll + // uses this to identify sessions saved by the last SaveAndTeardownAll: the + // presence of any row is the marker; preserved_ref may be empty for clean + // worktrees. + ListSessionWorktrees(ctx context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error) } // Manager coordinates internal session spawn, restore, kill, and cleanup over @@ -532,6 +541,164 @@ func (m *Manager) getRecord(ctx context.Context, id domain.SessionID) (domain.Se return rec, nil } +// SaveAndTeardownAll captures uncommitted work and tears down every live +// session that has a workspace path. It is the shutdown path for the daemon: +// each session's uncommitted work is stashed into a preserve ref, the ref is +// written to session_worktrees (the "shutdown-saved" marker) BEFORE the +// worktree is force-removed. The DB write is committed before the worktree is +// destroyed so a crash between the two leaves the ref in place and the row +// present; RestoreAll will replay both. +// +// Failures on individual sessions are logged and do not abort the loop. +// ForceDestroy is never called if capture or the DB write did not succeed. +func (m *Manager) SaveAndTeardownAll(ctx context.Context) error { + recs, err := m.store.ListAllSessions(ctx) + if err != nil { + return fmt.Errorf("save-teardown-all: list sessions: %w", err) + } + for _, rec := range recs { + if rec.IsTerminated { + continue + } + if rec.Metadata.WorkspacePath == "" || rec.Metadata.Branch == "" { + continue + } + if err := m.saveAndTeardownOne(ctx, rec); err != nil { + m.logger.Error("save-teardown-all: session failed, skipping", "sessionID", rec.ID, "error", err) + } + } + return nil +} + +// saveAndTeardownOne runs the capture-then-destroy sequence for a single +// session. The DB write (UpsertSessionWorktree) is committed before +// ForceDestroy; if either capture or the DB write fails, ForceDestroy is +// not called. +func (m *Manager) saveAndTeardownOne(ctx context.Context, rec domain.SessionRecord) error { + ws := workspaceInfo(rec) + + // 1. Capture uncommitted work (ref may be "" for clean worktrees). + ref, err := m.workspace.StashUncommitted(ctx, ws) + if err != nil { + return fmt.Errorf("save %s: stash: %w", rec.ID, err) + } + + // 2. Write the shutdown-saved marker to the DB. The row's presence (even + // with an empty preserved_ref) is what RestoreAll uses to identify sessions + // saved by this run. This MUST be committed before ForceDestroy. + row := domain.SessionWorktreeRecord{ + SessionID: rec.ID, + RepoName: domain.RootWorkspaceRepoName, + Branch: rec.Metadata.Branch, + WorktreePath: rec.Metadata.WorkspacePath, + PreservedRef: ref, + } + if err := m.store.UpsertSessionWorktree(ctx, row); err != nil { + return fmt.Errorf("save %s: upsert worktree row: %w", rec.ID, err) + } + + // 3. Mark terminal via the LCM (same path Kill uses). + if err := m.lcm.MarkTerminated(ctx, rec.ID); err != nil { + return fmt.Errorf("save %s: mark terminated: %w", rec.ID, err) + } + + // 4. Runtime teardown (best-effort; same pattern as Kill). + handle := runtimeHandle(rec.Metadata) + if handle.ID != "" { + if err := m.runtime.Destroy(ctx, handle); err != nil { + m.logger.Warn("save-teardown-all: runtime destroy failed", "sessionID", rec.ID, "error", err) + } + } + + // 5. Force-remove the worktree (safe: work is captured in step 1 and the + // DB write in step 2 is already committed). + if err := m.workspace.ForceDestroy(ctx, ws); err != nil { + m.logger.Warn("save-teardown-all: force destroy failed", "sessionID", rec.ID, "error", err) + } + return nil +} + +// RestoreAll relaunches every terminated session that was saved by the last +// SaveAndTeardownAll. The "shutdown-saved" marker is the presence of a +// session_worktrees row for the session; sessions the user killed before +// shutdown have no such row and are left terminated. +// +// For each saved session: +// 1. Ensure the worktree exists via workspace.Restore. +// 2. If a preserve ref is recorded, replay it via ApplyPreserved; on conflict +// log and continue (still relaunch the agent, never delete the ref). +// 3. Relaunch via the existing Restore method. +// +// Failures on individual sessions are logged and do not abort the loop. +func (m *Manager) RestoreAll(ctx context.Context) error { + recs, err := m.store.ListAllSessions(ctx) + if err != nil { + return fmt.Errorf("restore-all: list sessions: %w", err) + } + for _, rec := range recs { + if !rec.IsTerminated { + continue + } + // Check the shutdown-saved marker: is there a session_worktrees row? + rows, err := m.store.ListSessionWorktrees(ctx, rec.ID) + if err != nil { + m.logger.Error("restore-all: list worktrees failed", "sessionID", rec.ID, "error", err) + continue + } + if len(rows) == 0 { + // No marker: this session was killed by the user before shutdown. + continue + } + + // Collect the preserve ref (may be "" for clean worktrees). + var preserveRef string + for _, r := range rows { + if r.PreservedRef != "" { + preserveRef = r.PreservedRef + break + } + } + + // Step 1: ensure the worktree exists. workspace.Restore re-creates it + // if it was removed by SaveAndTeardownAll. + project, err := m.loadProject(ctx, rec.ProjectID) + if err != nil { + m.logger.Error("restore-all: load project failed", "sessionID", rec.ID, "error", err) + continue + } + ws, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{ + ProjectID: rec.ProjectID, + SessionID: rec.ID, + Kind: rec.Kind, + SessionPrefix: sessionPrefix(project), + Branch: rec.Metadata.Branch, + }) + if err != nil { + m.logger.Error("restore-all: workspace restore failed", "sessionID", rec.ID, "error", err) + continue + } + + // Step 2: replay preserve ref when one was recorded. + if preserveRef != "" { + if applyErr := m.workspace.ApplyPreserved(ctx, ws, preserveRef); applyErr != nil { + if errors.Is(applyErr, ports.ErrPreservedConflict) { + m.logger.Warn("restore-all: apply preserved produced conflicts; agent relaunched with conflict markers in place", + "sessionID", rec.ID, "ref", preserveRef, "error", applyErr) + } else { + m.logger.Error("restore-all: apply preserved failed", "sessionID", rec.ID, "error", applyErr) + } + // Continue: always relaunch even on conflict (never delete the ref here). + } + } + + // Step 3: relaunch via the existing single-session Restore method. + if _, err := m.Restore(ctx, rec.ID); err != nil { + m.logger.Error("restore-all: relaunch failed", "sessionID", rec.ID, "error", err) + } + } + return nil +} + // Send delivers a message to a running session's agent via the messenger. func (m *Manager) Send(ctx context.Context, id domain.SessionID, message string) error { if err := m.messenger.Send(ctx, id, message); err != nil { diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index ac32a94d..673a58bc 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -24,10 +24,17 @@ type fakeStore struct { projects map[string]domain.ProjectRecord num int deleteErr error + // worktrees maps session ID to its saved worktree rows (shutdown-saved marker). + worktrees map[domain.SessionID][]domain.SessionWorktreeRecord } func newFakeStore() *fakeStore { - return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}, projects: map[string]domain.ProjectRecord{}} + return &fakeStore{ + sessions: map[domain.SessionID]domain.SessionRecord{}, + pr: map[domain.SessionID]domain.PRFacts{}, + projects: map[string]domain.ProjectRecord{}, + worktrees: map[domain.SessionID][]domain.SessionWorktreeRecord{}, + } } func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) { r, ok := f.projects[id] @@ -84,6 +91,21 @@ func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.Ses } return domain.PRFacts{}, false, nil } +func (f *fakeStore) UpsertSessionWorktree(_ context.Context, row domain.SessionWorktreeRecord) error { + rows := f.worktrees[row.SessionID] + for i, r := range rows { + if r.RepoName == row.RepoName { + rows[i] = row + f.worktrees[row.SessionID] = rows + return nil + } + } + f.worktrees[row.SessionID] = append(rows, row) + return nil +} +func (f *fakeStore) ListSessionWorktrees(_ context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error) { + return f.worktrees[id], nil +} type fakeLCM struct { store *fakeStore @@ -186,13 +208,20 @@ type missingAgents struct{} func (missingAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return nil, false } type fakeWorkspace struct { - createErr error - destroyErr error - destroyed int - lastCfg ports.WorkspaceConfig + createErr error + destroyErr error + destroyed int + lastCfg ports.WorkspaceConfig // path, when set, is returned as the workspace path so provisioning tests // can point at a real temp directory. path string + // stashRef is returned by StashUncommitted (empty means clean worktree). + stashRef string + stashErr error + applyErr error + forceDestroyErr error + // calls records the sequence of workspace method calls for ordering assertions. + calls []string } func (w *fakeWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { @@ -213,12 +242,17 @@ func (w *fakeWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error { func (w *fakeWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { return w.Create(ctx, cfg) } -func (w *fakeWorkspace) ForceDestroy(context.Context, ports.WorkspaceInfo) error { return nil } -func (w *fakeWorkspace) StashUncommitted(_ context.Context, _ ports.WorkspaceInfo) (string, error) { - return "", nil +func (w *fakeWorkspace) ForceDestroy(_ context.Context, info ports.WorkspaceInfo) error { + w.calls = append(w.calls, "ForceDestroy:"+string(info.SessionID)) + return w.forceDestroyErr } -func (w *fakeWorkspace) ApplyPreserved(_ context.Context, _ ports.WorkspaceInfo, _ string) error { - return nil +func (w *fakeWorkspace) StashUncommitted(_ context.Context, info ports.WorkspaceInfo) (string, error) { + w.calls = append(w.calls, "StashUncommitted:"+string(info.SessionID)) + return w.stashRef, w.stashErr +} +func (w *fakeWorkspace) ApplyPreserved(_ context.Context, info ports.WorkspaceInfo, ref string) error { + w.calls = append(w.calls, "ApplyPreserved:"+string(info.SessionID)) + return w.applyErr } type fakeMessenger struct{ msgs []string } @@ -1083,3 +1117,338 @@ func TestSpawn_KeepsExplicitBranch(t *testing.T) { t.Fatalf("explicit branch = %q, want feature/x", got) } } + +// ---- SaveAndTeardownAll / RestoreAll tests ---- + +// newLifecycleManager builds a manager wired with a recording workspace fake +// for the shutdown lifecycle tests. +func newLifecycleManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()} + rt := &fakeRuntime{} + ws := &fakeWorkspace{} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{ + Runtime: rt, + Agents: fakeAgents{}, + Workspace: ws, + Store: st, + Messenger: &fakeMessenger{}, + Lifecycle: &fakeLCM{store: st}, + LookPath: lookPath, + }) + return m, st, rt, ws +} + +// TestSaveAndTeardownAll_CaptureOrderAndMarker verifies (a): for a live session +// with a workspace, SaveAndTeardownAll must call StashUncommitted BEFORE +// UpsertSessionWorktree (writing preserved_ref) BEFORE ForceDestroy. +func TestSaveAndTeardownAll_CaptureOrderAndMarker(t *testing.T) { + m, st, _, ws := newLifecycleManager() + // A live session with a workspace path and runtime handle. + ws.stashRef = "refs/ao/preserved/mer-1" + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", RuntimeHandleID: "h1"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + + if err := m.SaveAndTeardownAll(ctx); err != nil { + t.Fatalf("SaveAndTeardownAll err = %v", err) + } + + // Stash must come before ForceDestroy in the call log. + stashIdx, forceIdx := -1, -1 + for i, c := range ws.calls { + if c == "StashUncommitted:mer-1" { + stashIdx = i + } + if c == "ForceDestroy:mer-1" { + forceIdx = i + } + } + if stashIdx == -1 { + t.Fatal("StashUncommitted was not called") + } + if forceIdx == -1 { + t.Fatal("ForceDestroy was not called") + } + if stashIdx >= forceIdx { + t.Fatalf("StashUncommitted (call %d) must come before ForceDestroy (call %d)", stashIdx, forceIdx) + } + + // DB write (UpsertSessionWorktree) must happen before ForceDestroy: + // the worktree row must be present for the session. + rows := st.worktrees["mer-1"] + if len(rows) == 0 { + t.Fatal("UpsertSessionWorktree was not called: no worktree row for mer-1") + } + if rows[0].PreservedRef != "refs/ao/preserved/mer-1" { + t.Fatalf("preserved_ref = %q, want refs/ao/preserved/mer-1", rows[0].PreservedRef) + } + + // The session must be marked terminated. + if !st.sessions["mer-1"].IsTerminated { + t.Fatal("session must be terminated after SaveAndTeardownAll") + } +} + +// TestSaveAndTeardownAll_CleanWorktreeWritesEmptyRef verifies that a clean +// worktree (StashUncommitted returns "") still writes a worktree row (with +// empty preserved_ref). The row's presence is the shutdown-saved marker. +func TestSaveAndTeardownAll_CleanWorktreeWritesEmptyRef(t *testing.T) { + m, st, _, ws := newLifecycleManager() + ws.stashRef = "" // clean worktree + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", RuntimeHandleID: "h1"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + + if err := m.SaveAndTeardownAll(ctx); err != nil { + t.Fatalf("SaveAndTeardownAll err = %v", err) + } + + rows := st.worktrees["mer-1"] + if len(rows) == 0 { + t.Fatal("clean worktree must still write a session_worktrees row as the shutdown-saved marker") + } + if rows[0].PreservedRef != "" { + t.Fatalf("preserved_ref = %q, want empty for clean worktree", rows[0].PreservedRef) + } +} + +// TestSaveAndTeardownAll_SkipsNoWorkspacePath: sessions without a workspace +// path are skipped (spawn failed before workspace.Create). +func TestSaveAndTeardownAll_SkipsNoWorkspacePath(t *testing.T) { + m, st, _, ws := newLifecycleManager() + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Metadata: domain.SessionMetadata{}, // no workspace path + Activity: domain.Activity{State: domain.ActivityActive}, + } + + if err := m.SaveAndTeardownAll(ctx); err != nil { + t.Fatalf("SaveAndTeardownAll err = %v", err) + } + + if len(ws.calls) != 0 { + t.Fatalf("no workspace calls expected for sessions with no workspace path, got %v", ws.calls) + } + if len(st.worktrees["mer-1"]) != 0 { + t.Fatal("no worktree row should be written for sessions with no workspace path") + } +} + +// TestSaveAndTeardownAll_SkipsAlreadyTerminated: already-terminated sessions +// are skipped. +func TestSaveAndTeardownAll_SkipsAlreadyTerminated(t *testing.T) { + m, st, _, ws := newLifecycleManager() + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + + if err := m.SaveAndTeardownAll(ctx); err != nil { + t.Fatalf("SaveAndTeardownAll err = %v", err) + } + if len(ws.calls) != 0 { + t.Fatalf("already-terminated sessions must be skipped, got calls %v", ws.calls) + } +} + +// TestSaveAndTeardownAll_NoKindFilter: both worker and orchestrator sessions +// are saved (no kind filter). +func TestSaveAndTeardownAll_NoKindFilter(t *testing.T) { + m, st, _, _ := newLifecycleManager() + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", RuntimeHandleID: "h1"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.sessions["mer-2"] = domain.SessionRecord{ + ID: "mer-2", ProjectID: "mer", Kind: domain.KindOrchestrator, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-2", Branch: "ao/mer-orchestrator", RuntimeHandleID: "h2"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + + if err := m.SaveAndTeardownAll(ctx); err != nil { + t.Fatalf("SaveAndTeardownAll err = %v", err) + } + + if len(st.worktrees["mer-1"]) == 0 { + t.Error("worker session mer-1 must be saved") + } + if len(st.worktrees["mer-2"]) == 0 { + t.Error("orchestrator session mer-2 must be saved") + } + if !st.sessions["mer-1"].IsTerminated { + t.Error("worker session mer-1 must be terminated") + } + if !st.sessions["mer-2"].IsTerminated { + t.Error("orchestrator session mer-2 must be terminated") + } +} + +// TestRestoreAll_RestoresBothWorkerAndOrchestrator verifies (b): RestoreAll +// restores both a worker and an orchestrator session saved by SaveAndTeardownAll. +func TestRestoreAll_RestoresBothWorkerAndOrchestrator(t *testing.T) { + m, st, rt, _ := newLifecycleManager() + + // Seed two terminated sessions that were saved by SaveAndTeardownAll + // (presence of session_worktrees rows is the marker). + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Harness: domain.HarnessClaudeCode, + IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + st.sessions["mer-2"] = domain.SessionRecord{ + ID: "mer-2", + ProjectID: "mer", + Kind: domain.KindOrchestrator, + Harness: domain.HarnessClaudeCode, + IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-2", Branch: "ao/mer-orchestrator", AgentSessionID: "agent-o"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + // Write the shutdown-saved marker rows. + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__", PreservedRef: ""}} + st.worktrees["mer-2"] = []domain.SessionWorktreeRecord{{SessionID: "mer-2", RepoName: "__root__", PreservedRef: ""}} + + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("RestoreAll err = %v", err) + } + + if rt.created != 2 { + t.Fatalf("RestoreAll must relaunch both sessions, runtime.Create called %d times", rt.created) + } + if st.sessions["mer-1"].IsTerminated { + t.Error("worker session mer-1 must be live after RestoreAll") + } + if st.sessions["mer-2"].IsTerminated { + t.Error("orchestrator session mer-2 must be live after RestoreAll") + } +} + +// TestRestoreAll_SkipsSessionsKilledBeforeShutdown verifies (c): a session +// the user killed BEFORE shutdown has no session_worktrees row and must NOT +// be resurrected. +func TestRestoreAll_SkipsSessionsKilledBeforeShutdown(t *testing.T) { + m, st, rt, _ := newLifecycleManager() + + // This session was killed by the user before shutdown: IsTerminated=true, + // but no session_worktrees row (SaveAndTeardownAll skipped it). + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Harness: domain.HarnessClaudeCode, + IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", Prompt: "do it"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + // Deliberately no entry in st.worktrees for mer-1. + + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("RestoreAll err = %v", err) + } + + if rt.created != 0 { + t.Fatalf("user-killed session must not be restored, runtime.Create called %d times", rt.created) + } + if !st.sessions["mer-1"].IsTerminated { + t.Error("user-killed session must remain terminated") + } +} + +// TestRestoreAll_AppliesPreservedRef: when the session_worktrees row has a +// non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace +// restore but before relaunching. +func TestRestoreAll_AppliesPreservedRef(t *testing.T) { + m, st, rt, ws := newLifecycleManager() + + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Harness: domain.HarnessClaudeCode, + IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: "__root__", PreservedRef: "refs/ao/preserved/mer-1"}, + } + + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("RestoreAll err = %v", err) + } + + applied := false + for _, c := range ws.calls { + if c == "ApplyPreserved:mer-1" { + applied = true + } + } + if !applied { + t.Fatal("ApplyPreserved was not called for session with preserved_ref") + } + if rt.created != 1 { + t.Fatal("session must still be relaunched even after ApplyPreserved") + } +} + +// TestRestoreAll_ConflictLogsAndContinues: when ApplyPreserved returns +// ErrPreservedConflict, RestoreAll logs and continues (still relaunches). +func TestRestoreAll_ConflictLogsAndContinues(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()} + rt := &fakeRuntime{} + ws := &fakeWorkspace{applyErr: fmt.Errorf("conflict: %w", ports.ErrPreservedConflict)} + var logBuf bytes.Buffer + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{ + Runtime: rt, + Agents: fakeAgents{}, + Workspace: ws, + Store: st, + Messenger: &fakeMessenger{}, + Lifecycle: &fakeLCM{store: st}, + LookPath: lookPath, + Logger: slog.New(slog.NewTextHandler(&logBuf, nil)), + }) + + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Harness: domain.HarnessClaudeCode, + IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: "__root__", PreservedRef: "refs/ao/preserved/mer-1"}, + } + + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("RestoreAll err = %v; conflict must not abort", err) + } + if rt.created != 1 { + t.Fatalf("session must still relaunch after conflict, runtime.Create called %d times", rt.created) + } +} From de03bf8a87adf660e2ce558ebb1662927c886830 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 24 Jun 2026 05:36:16 +0530 Subject: [PATCH 31/60] fix(terminal): enable tmux mouse scroll and fix link clicking On macOS the runtime is tmux, but two mouse interactions were broken in the embedded terminal while copy/paste kept working: - Scroll: the renderer drives scrolling by writing SGR mouse-wheel reports into the pane (the zellij `--mouse-mode true` model), but tmux ignores those reports unless mouse mode is on. Create only set `status off`, never `mouse on`, so wheel scrolling silently no-opped. Enable `set-option -t mouse on`, mirroring the existing status-off step. - Link clicking: the default WebLinksAddon handler calls window.open() with an empty URL and then assigns location.href. Electron's setWindowOpenHandler denies every window.open and only forwards the URL passed to it, so the empty open is dropped and clicks no-op. Pass the matched URL to window.open directly so the main process routes it to shell.openExternal (the OS browser). Co-Authored-By: Claude Opus 4.8 --- .../adapters/runtime/tmux/commands.go | 7 +++ .../internal/adapters/runtime/tmux/tmux.go | 16 ++++-- .../adapters/runtime/tmux/tmux_test.go | 26 +++++---- .../components/XtermTerminal.test.tsx | 21 +++++++- .../src/renderer/components/XtermTerminal.tsx | 54 +++++++++++-------- 5 files changed, 86 insertions(+), 38 deletions(-) diff --git a/backend/internal/adapters/runtime/tmux/commands.go b/backend/internal/adapters/runtime/tmux/commands.go index 1c2cf302..1a55d363 100644 --- a/backend/internal/adapters/runtime/tmux/commands.go +++ b/backend/internal/adapters/runtime/tmux/commands.go @@ -23,6 +23,13 @@ func setStatusOffArgs(id string) []string { return []string{"set-option", "-t", id, "status", "off"} } +// setMouseOnArgs enables tmux mouse mode so the terminal's SGR mouse-wheel +// reports scroll the pane via copy-mode; without it, wheel scrolling no-ops. +// Pane-targeting, so no `=` prefix (see setStatusOffArgs). +func setMouseOnArgs(id string) []string { + return []string{"set-option", "-t", id, "mouse", "on"} +} + // killSessionArgs builds args for `tmux kill-session -t =`. The `=` prefix // requests exact-name matching so a session "foo" does not accidentally match // "foobar" (tmux otherwise does unique-prefix matching). diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go index 4cd00e94..7dfb1135 100644 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -129,6 +129,13 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: set status %s: %w", id, err) } + // Enable mouse mode so the embedded terminal's SGR wheel reports scroll the + // pane (see setMouseOnArgs). Without it, wheel scrolling silently no-ops. + if _, err := r.run(ctx, setMouseOnArgs(id)...); err != nil { + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) + return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: set mouse %s: %w", id, err) + } + handle := ports.RuntimeHandle{ID: id} alive, err := r.IsAlive(ctx, handle) if err != nil { @@ -233,8 +240,7 @@ func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, } // attachCommand returns the argv a human runs to attach their terminal to the -// session. No per-session env block is needed for tmux (unlike zellij's Windows -// ConPTY path), so env is always nil. +// session. tmux needs no per-session env block, so env is always nil. func (r *Runtime) attachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { id, err := handleID(handle) if err != nil { @@ -333,7 +339,7 @@ func killSessionMissingOutput(out string) bool { return sessionMissingOutput(out) } -// -- text helpers (ported from zellij) -- +// -- text helpers -- func chunks(s string, maxBytes int) []string { if s == "" { @@ -434,8 +440,8 @@ func shellQuote(s string) string { // configured shell). It exports env vars, then runs argv, then execs a // keep-alive interactive shell so the tmux session survives the agent exiting. // -// ponytail: PATH handling matches the zellij unix path: PATH from cfg.Env is -// exported last, after all other keys, so an explicit override takes effect. +// PATH from cfg.Env is exported last, after all other keys, so an explicit +// override takes effect. func buildLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { path := cfg.Env["PATH"] if path == "" { diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go index a5979b75..254e3097 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -79,6 +79,9 @@ func TestCommandBuilders(t *testing.T) { if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "sess-1", "status", "off"}; !reflect.DeepEqual(got, want) { t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want) } + if got, want := setMouseOnArgs("sess-1"), []string{"set-option", "-t", "sess-1", "mouse", "on"}; !reflect.DeepEqual(got, want) { + t.Fatalf("setMouseOnArgs = %#v, want %#v", got, want) + } // kill-session and has-session use exact-match prefix =. if got, want := killSessionArgs("sess-1"), []string{"kill-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { t.Fatalf("killSessionArgs = %#v, want %#v", got, want) @@ -154,9 +157,9 @@ func TestCreateRejectsInvalidEnvKeys(t *testing.T) { // -- Create tests -- func TestCreateIssuesNewSessionAndStatusOff(t *testing.T) { - // new-session output (ignored), set-option output, has-session output (exit 0 = alive) + // new-session, set-option status, set-option mouse, has-session (exit 0 = alive) r, fr := newTestRuntime(0) - fr.outputs = [][]byte{nil, nil, nil} + fr.outputs = [][]byte{nil, nil, nil, nil} h, err := r.Create(context.Background(), ports.RuntimeConfig{ SessionID: "sess-1", @@ -170,9 +173,9 @@ func TestCreateIssuesNewSessionAndStatusOff(t *testing.T) { if h.ID != "sess-1" { t.Fatalf("handle ID = %q, want sess-1", h.ID) } - // Expect 3 calls: new-session, set-option, has-session. - if len(fr.calls) != 3 { - t.Fatalf("calls = %d, want 3", len(fr.calls)) + // Expect 4 calls: new-session, set-option status, set-option mouse, has-session. + if len(fr.calls) != 4 { + t.Fatalf("calls = %d, want 4", len(fr.calls)) } // Call 0: new-session @@ -197,10 +200,15 @@ func TestCreateIssuesNewSessionAndStatusOff(t *testing.T) { t.Fatalf("call[1] = %#v, want %#v", got, want) } - // Call 2: has-session (IsAlive, uses exact-match target =sess-1). - if got, want := fr.calls[2].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { + // Call 2: set-option mouse on (enables wheel-scroll of the pane). + if got, want := fr.calls[2].args, setMouseOnArgs("sess-1"); !reflect.DeepEqual(got, want) { t.Fatalf("call[2] = %#v, want %#v", got, want) } + + // Call 3: has-session (IsAlive, uses exact-match target =sess-1). + if got, want := fr.calls[3].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { + t.Fatalf("call[3] = %#v, want %#v", got, want) + } } func TestCreateLaunchCommandContainsKeepAliveShell(t *testing.T) { @@ -268,8 +276,8 @@ func TestCreateLaunchCommandExportsEnvVars(t *testing.T) { func TestCreateDestroysAndReturnsErrorWhenNotAlive(t *testing.T) { // Use a specialized fakeRunner that returns an exit error only for the 3rd call. r2, _ := newTestRuntime(0) - fr3 := &fakeRunnerSelectiveErr{exitErrAt: 2} - fr3.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} + fr3 := &fakeRunnerSelectiveErr{exitErrAt: 3} + fr3.outputs = [][]byte{nil, nil, nil, []byte("can't find session: sess-1")} r2.runner = fr3 _, err := r2.Create(context.Background(), ports.RuntimeConfig{ diff --git a/frontend/src/renderer/components/XtermTerminal.test.tsx b/frontend/src/renderer/components/XtermTerminal.test.tsx index 3787148c..59892b55 100644 --- a/frontend/src/renderer/components/XtermTerminal.test.tsx +++ b/frontend/src/renderer/components/XtermTerminal.test.tsx @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { XtermTerminal } from "./XtermTerminal"; const state = vi.hoisted(() => ({ + linkHandler: null as null | ((event: MouseEvent, uri: string) => void), lastTerminal: null as null | { keyHandler?: (event: KeyboardEvent) => boolean; wheelHandler?: (event: WheelEvent) => boolean; @@ -103,7 +104,11 @@ vi.mock("@xterm/addon-unicode11", () => ({ })); vi.mock("@xterm/addon-web-links", () => ({ - WebLinksAddon: class FakeWebLinksAddon {}, + WebLinksAddon: class FakeWebLinksAddon { + constructor(handler?: (event: MouseEvent, uri: string) => void) { + state.linkHandler = handler ?? null; + } + }, })); vi.mock("@xterm/addon-canvas", () => ({ @@ -131,6 +136,7 @@ function setNavigatorPlatform(platform: string) { describe("XtermTerminal", () => { beforeEach(() => { state.lastTerminal = null; + state.linkHandler = null; setNavigatorPlatform("Linux x86_64"); window.ao!.clipboard.writeText = vi.fn().mockResolvedValue(undefined); window.ao!.clipboard.readText = vi.fn().mockResolvedValue(""); @@ -486,6 +492,19 @@ describe("XtermTerminal", () => { expect(onInput).not.toHaveBeenCalled(); }); + it("opens terminal links via window.open so Electron routes them to the OS browser", () => { + const open = vi.spyOn(window, "open").mockReturnValue(null); + render(); + + // The default WebLinksAddon handler opens an empty window first, which the + // Electron main process denies; ours must pass the matched URL directly. + expect(state.linkHandler).toBeTypeOf("function"); + state.linkHandler!({} as MouseEvent, "https://example.com"); + + expect(open).toHaveBeenCalledWith("https://example.com", "_blank", "noopener"); + open.mockRestore(); + }); + it("forces plain drag selection without raw xterm data forwarding", () => { render(); diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx index 625e80fa..48de2c8d 100644 --- a/frontend/src/renderer/components/XtermTerminal.tsx +++ b/frontend/src/renderer/components/XtermTerminal.tsx @@ -70,11 +70,10 @@ function loadRenderer(term: Terminal): void { const terminalThemes = buildTerminalThemes(); const SUPPRESS_NATIVE_PASTE_MS = 100; -// Erase scrollback (3J) + display (2J) and home the cursor — yyork's -// terminalResetSequence. Deliberately NOT term.reset(): every pane PTY is a -// fresh per-client `zellij attach` whose handshake re-asserts terminal modes -// anyway, but a full RIS would drop them for the window until that handshake -// arrives. The clear only wipes pixels; modes stay up. +// Erase scrollback (3J) + display (2J) and home the cursor. Deliberately NOT +// term.reset(): every pane PTY is a fresh per-client attach whose handshake +// re-asserts terminal modes anyway, but a full RIS would drop them until that +// handshake arrives. The clear only wipes pixels; modes stay up. const CLEAR_SEQUENCE = "\x1b[3J\x1b[2J\x1b[H"; function preparePastedText(text: string): string { @@ -163,14 +162,13 @@ type XtermInternal = Terminal & { }; }; -// zellij (started with `--mouse-mode true`, see backend embeddedClientOptions) -// acts on SGR mouse-wheel reports written to its stdin and scrolls the focused -// pane, but it does NOT enable host mouse reporting, so xterm's own mouse -// protocol stays NONE and it never reports the wheel itself. With scrollback:0 -// xterm would instead convert the wheel into cursor-arrow keys (its alt-buffer -// fallback), which move the agent's cursor/history rather than scrolling. So we -// synthesize the SGR wheel reports here. SGR button 64 = wheel up, 65 = down; -// reports are 1-based and a single cell is enough for a borderless single pane. +// We never scroll locally (scrollback:0). Instead we synthesize SGR mouse-wheel +// reports and write them to the pane; tmux (with `mouse on`, set by the runtime +// adapter) acts on them and scrolls its scrollback via copy-mode. With +// scrollback:0 xterm would otherwise convert the wheel into cursor-arrow keys +// (its alt-buffer fallback), which move the agent's cursor rather than scrolling. +// SGR button 64 = wheel up, 65 = down; reports are 1-based and a single cell is +// enough for a borderless single pane. const SGR_WHEEL_UP = 64; const SGR_WHEEL_DOWN = 65; @@ -243,12 +241,12 @@ export function XtermTerminal(props: XtermTerminalProps) { // background, the way VS Code's terminal does; without it dim colors // render washed out. minimumContrastRatio: 4.5, - // The mux PTY runs `zellij attach` (backend AttachCommand), a - // full-screen alt-buffer app that owns scrollback itself — same as - // yyork. xterm's own buffer never accumulates history (the alt screen - // doesn't feed scrollback), and wheel events reach zellij as mouse - // reports instead of scrolling locally. 0 also stops FitAddon - // reserving ~14px on the right for a scrollbar that can never appear. + // The pane PTY runs a full-screen alt-buffer app (tmux attach) that + // owns scrollback itself, so xterm's own buffer never accumulates + // history (the alt screen doesn't feed scrollback) and wheel events + // are forwarded as mouse reports instead of scrolling locally. 0 also + // stops FitAddon reserving ~14px on the right for a scrollbar that can + // never appear. scrollback: 0, theme: props.theme === "dark" ? terminalThemes.dark : terminalThemes.light, }); @@ -264,7 +262,17 @@ export function XtermTerminal(props: XtermTerminalProps) { const unicode = new Unicode11Addon(); term.loadAddon(unicode); term.unicode.activeVersion = "11"; - term.loadAddon(new WebLinksAddon()); + // Open links in the OS browser. The default WebLinksAddon handler calls + // window.open() with no URL and then assigns location.href, but the + // Electron main process denies every window.open and only forwards the URL + // passed to it (main.ts setWindowOpenHandler), so the default handler's + // empty open is dropped and clicks silently no-op. Pass the matched URL to + // window.open directly so the main process routes it to shell.openExternal. + term.loadAddon( + new WebLinksAddon((_event, uri) => { + window.open(uri, "_blank", "noopener"); + }), + ); term.loadAddon(new SearchAddon()); term.open(host); @@ -379,8 +387,8 @@ export function XtermTerminal(props: XtermTerminalProps) { // 50/250ms catch the common settle; 600/1200ms are a session-bounded // backstop. By 600ms the WebGL atlas and font metrics are unambiguously // warm, so even if the convergence loop below detached at a briefly-stable - // wrong measurement, this re-measures the real cell box and corrects — - // which fires the PTY resize that makes zellij repaint cleanly (clearing + // wrong measurement, this re-measures the real cell box and corrects, + // firing the PTY resize that makes the pane repaint cleanly (clearing // any ghost frame). fit() is idempotent: a no-op when the grid is already // right, so a correct terminal never reflows. const settleTimers = [50, 250, 600, 1200].map((ms) => window.setTimeout(fitTerminal, ms)); @@ -454,7 +462,7 @@ export function XtermTerminal(props: XtermTerminalProps) { // composition, shortcuts, and wheel reports are emitted explicitly below. const keyInput = term.onKey(({ key }) => emitUserInput(key, "keyboard")); - // Translate wheel motion into SGR wheel reports for zellij (see + // Translate wheel motion into SGR wheel reports for the pane (see // sgrWheelReport), one report per scrolled line. WheelEvent.deltaMode // varies by platform/device: trackpads and normalized wheels report // pixels (mode 0, the macOS case), while many Linux/Windows mouse wheels From 4f1343228d6034a58903abd753a921209835e033 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 05:54:55 +0530 Subject: [PATCH 32/60] test(session-manager): assert UpsertSessionWorktree precedes ForceDestroy Add a shared ordered call log (sharedLog *[]string) to both fakeStore and fakeWorkspace. TestSaveAndTeardownAll_CaptureOrderAndMarker now wires both fakes to the same slice and asserts upsertIdx < forceIdx, enforcing the crash-safety invariant that the DB write is committed before the worktree is force-destroyed. Co-Authored-By: Claude Opus 4.8 --- .../internal/session_manager/manager_test.go | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 673a58bc..b90bf3c4 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -26,6 +26,9 @@ type fakeStore struct { deleteErr error // worktrees maps session ID to its saved worktree rows (shutdown-saved marker). worktrees map[domain.SessionID][]domain.SessionWorktreeRecord + // sharedLog, when non-nil, receives an ordered call entry for each + // UpsertSessionWorktree invocation so ordering tests can compare across fakes. + sharedLog *[]string } func newFakeStore() *fakeStore { @@ -92,6 +95,9 @@ func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.Ses return domain.PRFacts{}, false, nil } func (f *fakeStore) UpsertSessionWorktree(_ context.Context, row domain.SessionWorktreeRecord) error { + if f.sharedLog != nil { + *f.sharedLog = append(*f.sharedLog, "UpsertSessionWorktree:"+string(row.SessionID)) + } rows := f.worktrees[row.SessionID] for i, r := range rows { if r.RepoName == row.RepoName { @@ -216,12 +222,15 @@ type fakeWorkspace struct { // can point at a real temp directory. path string // stashRef is returned by StashUncommitted (empty means clean worktree). - stashRef string - stashErr error - applyErr error + stashRef string + stashErr error + applyErr error forceDestroyErr error // calls records the sequence of workspace method calls for ordering assertions. calls []string + // sharedLog, when non-nil, receives entries alongside calls so ordering + // tests can compare workspace calls against store calls in one sequence. + sharedLog *[]string } func (w *fakeWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { @@ -243,11 +252,19 @@ func (w *fakeWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) return w.Create(ctx, cfg) } func (w *fakeWorkspace) ForceDestroy(_ context.Context, info ports.WorkspaceInfo) error { - w.calls = append(w.calls, "ForceDestroy:"+string(info.SessionID)) + entry := "ForceDestroy:" + string(info.SessionID) + w.calls = append(w.calls, entry) + if w.sharedLog != nil { + *w.sharedLog = append(*w.sharedLog, entry) + } return w.forceDestroyErr } func (w *fakeWorkspace) StashUncommitted(_ context.Context, info ports.WorkspaceInfo) (string, error) { - w.calls = append(w.calls, "StashUncommitted:"+string(info.SessionID)) + entry := "StashUncommitted:" + string(info.SessionID) + w.calls = append(w.calls, entry) + if w.sharedLog != nil { + *w.sharedLog = append(*w.sharedLog, entry) + } return w.stashRef, w.stashErr } func (w *fakeWorkspace) ApplyPreserved(_ context.Context, info ports.WorkspaceInfo, ref string) error { @@ -1145,6 +1162,13 @@ func newLifecycleManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) // UpsertSessionWorktree (writing preserved_ref) BEFORE ForceDestroy. func TestSaveAndTeardownAll_CaptureOrderAndMarker(t *testing.T) { m, st, _, ws := newLifecycleManager() + + // Wire a shared ordered call log so we can assert cross-fake ordering: + // both fakeStore and fakeWorkspace append to the same slice. + var sharedLog []string + st.sharedLog = &sharedLog + ws.sharedLog = &sharedLog + // A live session with a workspace path and runtime handle. ws.stashRef = "refs/ao/preserved/mer-1" st.sessions["mer-1"] = domain.SessionRecord{ @@ -1179,8 +1203,28 @@ func TestSaveAndTeardownAll_CaptureOrderAndMarker(t *testing.T) { t.Fatalf("StashUncommitted (call %d) must come before ForceDestroy (call %d)", stashIdx, forceIdx) } - // DB write (UpsertSessionWorktree) must happen before ForceDestroy: - // the worktree row must be present for the session. + // UpsertSessionWorktree (DB write) must be committed BEFORE ForceDestroy. + // Use the shared ordered log to compare positions across the store and workspace. + upsertIdx, sharedForceIdx := -1, -1 + for i, c := range sharedLog { + if c == "UpsertSessionWorktree:mer-1" { + upsertIdx = i + } + if c == "ForceDestroy:mer-1" { + sharedForceIdx = i + } + } + if upsertIdx == -1 { + t.Fatal("UpsertSessionWorktree was not called") + } + if sharedForceIdx == -1 { + t.Fatal("ForceDestroy was not recorded in shared log") + } + if upsertIdx >= sharedForceIdx { + t.Fatalf("UpsertSessionWorktree (pos %d) must come before ForceDestroy (pos %d) in shared call log %v", upsertIdx, sharedForceIdx, sharedLog) + } + + // DB write (UpsertSessionWorktree) must have recorded the correct row. rows := st.worktrees["mer-1"] if len(rows) == 0 { t.Fatal("UpsertSessionWorktree was not called: no worktree row for mer-1") From e3d52cda7c60879415e0a6a38f266b37dff17ac4 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 06:00:00 +0530 Subject: [PATCH 33/60] feat(daemon): wire RestoreAll/SaveAndTeardownAll into boot/shutdown sequence Exposes session manager through a minimal sessionLifecycle interface (RestoreAll, SaveAndTeardownAll) returned from startSession, then calls RestoreAll (best-effort) before srv.Run and SaveAndTeardownAll with a fresh 30s-bounded context after srv.Run returns. Both SIGTERM and POST /shutdown funnel through srv.Run returning, so the single save call site covers both paths. Co-Authored-By: Claude Opus 4.8 --- backend/internal/daemon/daemon.go | 31 ++++++++++++- backend/internal/daemon/lifecycle_wiring.go | 22 ++++++--- backend/internal/daemon/wiring_test.go | 51 ++++++++++++++++++++- 3 files changed, 95 insertions(+), 9 deletions(-) diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index bc4a0e89..8026072d 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -109,7 +109,7 @@ func Run() error { // selected runtime, a gitworktree workspace, the per-session agent resolver // (AO_AGENT validated here for compatibility), and the agent messenger, then mount it // on the API. - sessionSvc, reviewSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, telemetrySink, log) + sessionSvc, reviewSvc, sessMgr, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, telemetrySink, log) if err != nil { stop() lcStack.Stop() @@ -141,12 +141,35 @@ func Run() error { return err } + // Restore sessions saved by the previous daemon shutdown. Best-effort: a + // failure is logged but never blocks boot. Both SIGTERM and the graceful + // POST /shutdown path cancel ctx, which causes srv.Run to return; placing + // RestoreAll here (before srv.Run) ensures sessions are live before the + // server starts serving. + if restoreErr := sessMgr.RestoreAll(ctx); restoreErr != nil { + log.Error("restore sessions on boot failed", "err", restoreErr) + } + runErr := srv.Run(ctx) + // Save and tear down all live sessions before the store closes. Both SIGTERM + // and POST /shutdown funnel through srv.Run returning (the signal cancels ctx, + // which srv.Run watches; the HTTP handler cancels the same ctx via the + // shutdown endpoint), so this single call site covers both paths. + // + // Use a fresh context with a bounded deadline: the ctx that caused srv.Run + // to return is already cancelled, so passing it would abort the save + // immediately and leave every session unsaved. + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownSaveTimeout) + defer shutdownCancel() + if saveErr := sessMgr.SaveAndTeardownAll(shutdownCtx); saveErr != nil { + log.Error("save sessions on shutdown failed", "err", saveErr) + } + // Shut the background goroutines down in order: cancel the context FIRST so // their loops exit, then wait for them to drain. Doing this explicitly (not // via defer) avoids the LIFO trap where a Stop() that blocks on ctx-cancel - // runs before the cancel — which would hang any non-signal exit path. + // runs before the cancel: a non-signal exit path would hang otherwise. stop() <-previewDone lcStack.Stop() @@ -156,6 +179,10 @@ func Run() error { return runErr } +// shutdownSaveTimeout bounds the SaveAndTeardownAll call on shutdown so a +// pathological session cannot stall the process exit indefinitely. +const shutdownSaveTimeout = 30 * time.Second + // newLogger returns the daemon's slog logger. It writes to stderr so supervisors // can capture it separately from any structured stdout protocol added later. func newLogger() *slog.Logger { diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index add24730..ceccce24 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -58,18 +58,28 @@ func (l *lifecycleStack) Stop() { } } +// sessionLifecycle is the narrow surface of sessionmanager.Manager used for +// boot/shutdown wiring. A minimal interface keeps the daemon testable without +// depending on the concrete manager type. +type sessionLifecycle interface { + RestoreAll(ctx context.Context) error + SaveAndTeardownAll(ctx context.Context) error +} + // startSession builds the controller-facing session service: a session manager // over the selected runtime, a per-session gitworktree workspace, the shared // store + LCM, the per-session agent resolver, and the agent messenger. The -// returned service is mounted at httpd APIDeps.Sessions. -func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { +// returned service is mounted at httpd APIDeps.Sessions. It also returns the +// manager so the caller can wire RestoreAll/SaveAndTeardownAll into the +// boot/shutdown sequence. +func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, sessionLifecycle, error) { defaultAgent := cfg.Agent if defaultAgent == "" { defaultAgent = config.DefaultAgent } agents, err := buildAgentResolver(defaultAgent, log) if err != nil { - return nil, nil, err + return nil, nil, nil, err } ws, err := gitworktree.New(gitworktree.Options{ // Per-session worktrees live under the data dir, so a single AO_DATA_DIR @@ -81,7 +91,7 @@ func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlit RepoResolver: projectRepoResolver{store: store}, }) if err != nil { - return nil, nil, fmt.Errorf("session workspace: %w", err) + return nil, nil, nil, fmt.Errorf("session workspace: %w", err) } mgr := sessionmanager.New(sessionmanager.Deps{ Runtime: runtime, @@ -113,7 +123,7 @@ func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlit // writer. reviewers, err := reviewer.NewResolver() if err != nil { - return nil, nil, fmt.Errorf("reviewer resolver: %w", err) + return nil, nil, nil, fmt.Errorf("reviewer resolver: %w", err) } reviewEngine := reviewcore.New(reviewcore.Deps{ Store: store, @@ -123,7 +133,7 @@ func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlit Launcher: reviewcore.NewLauncher(reviewers, runtime), }) reviewSvc := reviewsvc.New(reviewEngine, store, reviewsvc.WithLifecycleReducer(lcm)) - return sessionSvc, reviewSvc, nil + return sessionSvc, reviewSvc, mgr, nil } // runtimeMessageSender is the narrow part of the concrete runtime needed by diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index eda8ac1a..92e3e193 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -152,7 +152,7 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { rt := runtimeselect.New(nil) messenger := newSessionMessenger(store, rt, log) - svc, reviewSvc, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) + svc, reviewSvc, lifecycle, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) if err != nil { t.Fatalf("startSession: %v", err) } @@ -162,6 +162,9 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { if reviewSvc == nil { t.Fatal("startSession returned nil review service") } + if lifecycle == nil { + t.Fatal("startSession returned nil session lifecycle") + } } type captureRuntimeSender struct { @@ -378,3 +381,49 @@ func TestProjectRepoResolver_ResolvesRegisteredProject(t *testing.T) { } } +// fakeSessionLifecycle records calls to RestoreAll and SaveAndTeardownAll so +// tests can assert the daemon wiring invokes both without needing a real runtime +// or worktree. +type fakeSessionLifecycle struct { + restoreAllCalled bool + saveAndTeardownCalled bool + restoreErr error + saveErr error +} + +func (f *fakeSessionLifecycle) RestoreAll(_ context.Context) error { + f.restoreAllCalled = true + return f.restoreErr +} + +func (f *fakeSessionLifecycle) SaveAndTeardownAll(_ context.Context) error { + f.saveAndTeardownCalled = true + return f.saveErr +} + +// TestWiring_SessionLifecycleInterfaceInvokedByDaemon asserts the +// sessionLifecycle interface is satisfied by *sessionmanager.Manager (compile +// check) and that both methods are callable through the interface, matching what +// daemon.go wires at boot/shutdown. +func TestWiring_SessionLifecycleInterfaceInvokedByDaemon(t *testing.T) { + // Verify *sessionmanager.Manager satisfies the interface at compile time. + var _ sessionLifecycle = (*sessionmanager.Manager)(nil) + + fake := &fakeSessionLifecycle{} + ctx := context.Background() + + if err := fake.RestoreAll(ctx); err != nil { + t.Fatalf("RestoreAll: %v", err) + } + if !fake.restoreAllCalled { + t.Fatal("RestoreAll was not called through the interface") + } + + if err := fake.SaveAndTeardownAll(ctx); err != nil { + t.Fatalf("SaveAndTeardownAll: %v", err) + } + if !fake.saveAndTeardownCalled { + t.Fatal("SaveAndTeardownAll was not called through the interface") + } +} + From 7efabff12ea6699ae67a7990a007c3a2cd70b1bf Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 06:03:20 +0530 Subject: [PATCH 34/60] test(daemon): fix seam-test tautology and lifecycle variable shadow Finding 1: dispatch both sessionLifecycle methods through an interface variable (var sl sessionLifecycle = fake) so the runtime body exercises interface dispatch, not just direct struct method calls. Finding 2: rename local variable 'lifecycle' to 'lc' in TestWiring_StartSessionBuildsSessionService to remove the shadow of the imported lifecycle package. Co-Authored-By: Claude Opus 4.8 --- backend/internal/daemon/wiring_test.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 92e3e193..16246fd6 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -152,7 +152,7 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { rt := runtimeselect.New(nil) messenger := newSessionMessenger(store, rt, log) - svc, reviewSvc, lifecycle, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) + svc, reviewSvc, lc, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) if err != nil { t.Fatalf("startSession: %v", err) } @@ -162,7 +162,7 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { if reviewSvc == nil { t.Fatal("startSession returned nil review service") } - if lifecycle == nil { + if lc == nil { t.Fatal("startSession returned nil session lifecycle") } } @@ -403,8 +403,8 @@ func (f *fakeSessionLifecycle) SaveAndTeardownAll(_ context.Context) error { // TestWiring_SessionLifecycleInterfaceInvokedByDaemon asserts the // sessionLifecycle interface is satisfied by *sessionmanager.Manager (compile -// check) and that both methods are callable through the interface, matching what -// daemon.go wires at boot/shutdown. +// check) and that both methods dispatch correctly through the interface, matching +// what daemon.go wires at boot/shutdown. func TestWiring_SessionLifecycleInterfaceInvokedByDaemon(t *testing.T) { // Verify *sessionmanager.Manager satisfies the interface at compile time. var _ sessionLifecycle = (*sessionmanager.Manager)(nil) @@ -412,14 +412,18 @@ func TestWiring_SessionLifecycleInterfaceInvokedByDaemon(t *testing.T) { fake := &fakeSessionLifecycle{} ctx := context.Background() - if err := fake.RestoreAll(ctx); err != nil { + // Dispatch through the interface variable to exercise the real dispatch + // path, not just direct struct method calls. + var sl sessionLifecycle = fake + + if err := sl.RestoreAll(ctx); err != nil { t.Fatalf("RestoreAll: %v", err) } if !fake.restoreAllCalled { t.Fatal("RestoreAll was not called through the interface") } - if err := fake.SaveAndTeardownAll(ctx); err != nil { + if err := sl.SaveAndTeardownAll(ctx); err != nil { t.Fatalf("SaveAndTeardownAll: %v", err) } if !fake.saveAndTeardownCalled { From 91f6d0ff330404de40bf37fbfc2d96357d02ff9d Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 06:06:08 +0530 Subject: [PATCH 35/60] feat(frontend): call POST /shutdown before killing daemon on quit In before-quit, POST /shutdown (8s AbortSignal.timeout) so the daemon saves sessions gracefully before the SIGTERM kill. Adds a re-entrancy guard (quitting flag) so a concurrent app.quit() cannot double-preventDefault. Falls back to killDaemon on fetch failure or timeout: quit is never blocked. Keeps the process.on('exit') SIGTERM fallback intact. Co-Authored-By: Claude Opus 4.8 --- frontend/src/main.ts | 57 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 3bebc74d..83c4365c 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -691,12 +691,63 @@ app.whenReady().then(() => { }); }); -app.on("before-quit", () => { +// Re-entrancy guard: the first before-quit fires, prevents default, does async +// work, then calls app.exit(). If app.quit() is called concurrently (e.g. from +// window-all-closed on non-darwin), the second before-quit fires while the first +// is still in flight. Without a guard it would preventDefault again and loop. +// With the guard set to true, the second invocation falls through and lets the +// quit proceed. app.exit() itself does NOT re-fire before-quit, so the guard +// mainly protects against a concurrent app.quit() race. +let quitting = false; + +app.on("before-quit", (event) => { browserViewHost?.dispose(); browserViewHost = null; - if (daemonProcess) { - killDaemon(daemonProcess); + + // Re-entrancy: if we already started the async quit sequence, let this + // invocation fall through so the app actually exits. + if (quitting) return; + quitting = true; + + // Capture the current daemon handle and port before any async gap so that + // a race with stopDaemon() cannot null them out underneath us. + const child = daemonProcess; + const port = daemonStatus.state === "ready" ? daemonStatus.port : undefined; + + if (!child) { + // No daemon we own: nothing to shut down. + return; } + + // Prevent the synchronous quit so we can ask the daemon to save gracefully + // before killing it. + event.preventDefault(); + + const doQuit = async () => { + // Best-effort graceful shutdown: POST /shutdown so the daemon flushes + // its session state before exiting. An ~8s timeout prevents a hung or + // absent daemon from blocking quit indefinitely. + if (port !== undefined) { + try { + await fetch(`http://127.0.0.1:${port}/shutdown`, { + method: "POST", + signal: AbortSignal.timeout(8_000), + }); + } catch { + // Timeout, network error, or daemon already gone: proceed to kill. + console.log(`AO: /shutdown fetch failed (port ${port}); proceeding with SIGTERM.`); + } + } + + // Kill the daemon process group (reaches the daemon behind any shell + // wrapper and its PTY children). + killDaemon(child); + + // Exit without re-firing before-quit (app.exit bypasses the event). + app.exit(0); + }; + + void doQuit(); }); // Last-resort teardown. before-quit covers the normal quit path, but app.exit() From f26dc228485cd9e5b86616a898da9e0ad67d0914 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 06:12:18 +0530 Subject: [PATCH 36/60] fix(storage): guard session_worktrees.state against empty-string CHECK violation; add ponytail comments The save path (saveAndTeardownOne) never sets domain.SessionWorktreeRecord.State, so it arrives at UpsertSessionWorktree as "". The generated upsert includes state in the INSERT column list, so the DB default ('active') is never applied and the CHECK constraint (state IN ('active', ...)) would fire at the first real shutdown. Fix: default to 'active' in the store adapter when row.State is "". No schema change, no migration, no gen edit. Also add ponytail: comments on the State field (domain type), the write path, and the read path, documenting that state is unused multi-repo scaffolding and that the upgrade path is to wire a real value when multi-repo worktree lifecycle states ship. Co-Authored-By: Claude Opus 4.8 --- backend/internal/domain/project.go | 7 ++++++- .../sqlite/store/session_worktree_store.go | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/backend/internal/domain/project.go b/backend/internal/domain/project.go index 3f872f09..fd0fc25e 100644 --- a/backend/internal/domain/project.go +++ b/backend/internal/domain/project.go @@ -56,5 +56,10 @@ type SessionWorktreeRecord struct { BaseSHA string WorktreePath string PreservedRef string - State string + // ponytail: State mirrors session_worktrees.state, an enum that is unused + // multi-repo scaffolding. The save/restore lifecycle reads and writes only + // PreservedRef and row presence; State is never set by any live code path + // and always resolves to the column default ('active' on insert). Wire it + // when multi-repo worktree lifecycle states actually ship. + State string } diff --git a/backend/internal/storage/sqlite/store/session_worktree_store.go b/backend/internal/storage/sqlite/store/session_worktree_store.go index 04cba5cc..9b6e1a14 100644 --- a/backend/internal/storage/sqlite/store/session_worktree_store.go +++ b/backend/internal/storage/sqlite/store/session_worktree_store.go @@ -14,6 +14,16 @@ import ( func (s *Store) UpsertSessionWorktree(ctx context.Context, row domain.SessionWorktreeRecord) error { s.writeMu.Lock() defer s.writeMu.Unlock() + // ponytail: session_worktrees.state is unused multi-repo scaffolding; no + // live code path sets domain.SessionWorktreeRecord.State, so it arrives + // here as "". The generated upsert includes state in the INSERT column list + // and the CHECK constraint rejects "". Default to 'active' (the column + // default) so the row stays valid without touching the schema or gen code. + // Wire a real value when multi-repo worktree lifecycle states ship. + state := row.State + if state == "" { + state = "active" + } return s.qw.UpsertSessionWorktree(ctx, gen.UpsertSessionWorktreeParams{ SessionID: row.SessionID, RepoName: row.RepoName, @@ -21,7 +31,7 @@ func (s *Store) UpsertSessionWorktree(ctx context.Context, row domain.SessionWor BaseSha: row.BaseSHA, WorktreePath: row.WorktreePath, PreservedRef: row.PreservedRef, - State: row.State, + State: state, }) } @@ -65,6 +75,8 @@ func sessionWorktreeFromGen(row gen.SessionWorktree) domain.SessionWorktreeRecor BaseSHA: row.BaseSha, WorktreePath: row.WorktreePath, PreservedRef: row.PreservedRef, - State: row.State, + // ponytail: state is read back from the DB but no caller uses it; + // it is unused multi-repo scaffolding (see UpsertSessionWorktree above). + State: row.State, } } From eeaf8759a6d7f2618bc62e99eaf17e51b8ee2752 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 06:17:03 +0530 Subject: [PATCH 37/60] test(storage): add real-SQLite test for empty-State guard in UpsertSessionWorktree Adds TestUpsertSessionWorktreeEmptyStateDefaultsToActive to the store test file. It inserts a SessionWorktreeRecord with State at zero value "" via UpsertSessionWorktree against a real SQLite DB, then reads the row back and asserts State == "active". This directly exercises the guard added in the prior commit and would fail if the guard were removed (the CHECK constraint rejects ""). Mirrors the helpers and setup pattern of TestSessionWorktreesRoundTrip exactly. Co-Authored-By: Claude Opus 4.8 --- .../storage/sqlite/store/store_test.go | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index 830ca88a..c6a56477 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -783,3 +783,41 @@ func TestSessionWorktreesRoundTrip(t *testing.T) { t.Fatalf("after delete = %#v err=%v", got, err) } } + +// TestUpsertSessionWorktreeEmptyStateDefaultsToActive exercises the guard in +// UpsertSessionWorktree: when State is left at its zero value "", the store +// must default it to "active" so the SQLite CHECK constraint is satisfied. +// Without the guard, the generated upsert would insert "" and the CHECK would +// reject it. This test catches any regression that removes that guard. +func TestUpsertSessionWorktreeEmptyStateDefaultsToActive(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "sw") + rec, err := s.CreateSession(ctx, sampleRecord("sw")) + if err != nil { + t.Fatalf("create session: %v", err) + } + + // State is intentionally left at zero value "" to exercise the guard. + row := domain.SessionWorktreeRecord{ + SessionID: rec.ID, + RepoName: domain.RootWorkspaceRepoName, + Branch: "ao/sw-1", + BaseSHA: "abc123", + WorktreePath: "/managed/sw/sw-1", + } + if err := s.UpsertSessionWorktree(ctx, row); err != nil { + t.Fatalf("upsert with empty State: %v", err) + } + + got, ok, err := s.GetSessionWorktree(ctx, rec.ID, domain.RootWorkspaceRepoName) + if err != nil { + t.Fatalf("get worktree: %v", err) + } + if !ok { + t.Fatal("worktree row not found after upsert") + } + if got.State != "active" { + t.Fatalf("State = %q, want %q", got.State, "active") + } +} From c1ca9c0b43c9af3894b42077abfdd911a28942d2 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 06:24:21 +0530 Subject: [PATCH 38/60] fix(comments): correct shutdown-mechanism and task-ref inaccuracies Fix 1: daemon.go comment near SaveAndTeardownAll now correctly states that POST /shutdown closes the shutdownRequested channel (not cancel ctx). Also tighten the RestoreAll comment to remove the inaccurate claim. Fix 2: remove "Task 2's" phrasing from ForceDestroy ponytail comment in workspace.go; condition still references StashUncommitted by name. Fix 3: add note in main.ts that the 8s fetch timeout is shorter than the daemon's 30s save bound, so a SIGTERM after fetch abort does not cut the in-flight save short. Co-Authored-By: Claude Opus 4.8 --- .../adapters/workspace/gitworktree/workspace.go | 2 +- backend/internal/daemon/daemon.go | 12 +++++------- frontend/src/main.ts | 4 ++++ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index 84551fba..c571b09a 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -196,7 +196,7 @@ func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error // residue remains. // // ponytail: only safe to call AFTER the session's uncommitted work has been -// captured by Task 2's StashUncommitted. Calling it before capture silently +// captured via StashUncommitted. Calling it before capture silently // discards agent work. For interactive teardown (ao session kill, ao cleanup) // use Destroy, which refuses dirty worktrees via ErrWorkspaceDirty. func (w *Workspace) ForceDestroy(ctx context.Context, info ports.WorkspaceInfo) error { diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 8026072d..03885c1d 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -142,10 +142,8 @@ func Run() error { } // Restore sessions saved by the previous daemon shutdown. Best-effort: a - // failure is logged but never blocks boot. Both SIGTERM and the graceful - // POST /shutdown path cancel ctx, which causes srv.Run to return; placing - // RestoreAll here (before srv.Run) ensures sessions are live before the - // server starts serving. + // failure is logged but never blocks boot. RestoreAll is placed here (before + // srv.Run) to ensure sessions are live before the server starts serving. if restoreErr := sessMgr.RestoreAll(ctx); restoreErr != nil { log.Error("restore sessions on boot failed", "err", restoreErr) } @@ -153,9 +151,9 @@ func Run() error { runErr := srv.Run(ctx) // Save and tear down all live sessions before the store closes. Both SIGTERM - // and POST /shutdown funnel through srv.Run returning (the signal cancels ctx, - // which srv.Run watches; the HTTP handler cancels the same ctx via the - // shutdown endpoint), so this single call site covers both paths. + // and POST /shutdown funnel through srv.Run returning (SIGTERM cancels ctx, + // which srv.Run selects on; POST /shutdown closes the shutdownRequested channel, + // which srv.Run also selects on), so this single call site covers both paths. // // Use a fresh context with a bounded deadline: the ctx that caused srv.Run // to return is already cancelled, so passing it would abort the save diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 83c4365c..2fdbab0a 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -727,6 +727,10 @@ app.on("before-quit", (event) => { // Best-effort graceful shutdown: POST /shutdown so the daemon flushes // its session state before exiting. An ~8s timeout prevents a hung or // absent daemon from blocking quit indefinitely. + // Note: the daemon's internal save bound is 30s (shutdownSaveTimeout), so + // if this fetch times out and we proceed to killDaemon (SIGTERM), the first + // SIGTERM only cancels the daemon's listen context; the daemon's in-flight + // save (on a fresh context) still runs to completion or its own 30s bound. if (port !== undefined) { try { await fetch(`http://127.0.0.1:${port}/shutdown`, { From 2016ee2f4ed1fb0c5c56b835148779285242cd39 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 06:40:59 +0530 Subject: [PATCH 39/60] chore: remove .superpowers workflow scratch from repo These SDD workflow artifacts (task briefs, agent reports, progress ledger, review packages) were committed by accident in prior work, against the .superpowers/sdd/.gitignore intent. Remove them from the repo; they remain local-only scratch. Co-Authored-By: Claude Opus 4.8 --- .../sdd/final-phaseB-review-package.txt | 73 - .superpowers/sdd/final-review-package.txt | 2380 ----------- .superpowers/sdd/progress.md | 52 - .superpowers/sdd/task-1-brief.md | 141 - .superpowers/sdd/task-1-report.md | 129 - .superpowers/sdd/task-1-review-package.txt | 2092 ---------- .superpowers/sdd/task-2-brief.md | 142 - .superpowers/sdd/task-2-report.md | 158 - .superpowers/sdd/task-2-review-package.txt | 1069 ----- .superpowers/sdd/task-b1-brief.md | 119 - .superpowers/sdd/task-b1-report.md | 126 - .superpowers/sdd/task-b1-review-package.txt | 912 ----- .superpowers/sdd/task-b2-brief.md | 102 - .superpowers/sdd/task-b2-report.md | 55 - .superpowers/sdd/task-b3-brief.md | 137 - .superpowers/sdd/task-b3-report.md | 148 - .superpowers/sdd/task-b3-review-package.txt | 1391 ------- .superpowers/sdd/task-b4-brief.md | 139 - .superpowers/sdd/task-b4-report.md | 103 - .superpowers/sdd/task-b4-review-package.txt | 1806 --------- .superpowers/sdd/task-b5-brief.md | 159 - .superpowers/sdd/task-b5-report.md | 146 - .superpowers/sdd/task-b5-review-package.txt | 3071 --------------- .superpowers/sdd/task-b6-brief.md | 105 - .superpowers/sdd/task-b6-report.md | 107 - .superpowers/sdd/task-b6-review-package.txt | 3502 ----------------- 26 files changed, 18364 deletions(-) delete mode 100644 .superpowers/sdd/final-phaseB-review-package.txt delete mode 100644 .superpowers/sdd/final-review-package.txt delete mode 100644 .superpowers/sdd/progress.md delete mode 100644 .superpowers/sdd/task-1-brief.md delete mode 100644 .superpowers/sdd/task-1-report.md delete mode 100644 .superpowers/sdd/task-1-review-package.txt delete mode 100644 .superpowers/sdd/task-2-brief.md delete mode 100644 .superpowers/sdd/task-2-report.md delete mode 100644 .superpowers/sdd/task-2-review-package.txt delete mode 100644 .superpowers/sdd/task-b1-brief.md delete mode 100644 .superpowers/sdd/task-b1-report.md delete mode 100644 .superpowers/sdd/task-b1-review-package.txt delete mode 100644 .superpowers/sdd/task-b2-brief.md delete mode 100644 .superpowers/sdd/task-b2-report.md delete mode 100644 .superpowers/sdd/task-b3-brief.md delete mode 100644 .superpowers/sdd/task-b3-report.md delete mode 100644 .superpowers/sdd/task-b3-review-package.txt delete mode 100644 .superpowers/sdd/task-b4-brief.md delete mode 100644 .superpowers/sdd/task-b4-report.md delete mode 100644 .superpowers/sdd/task-b4-review-package.txt delete mode 100644 .superpowers/sdd/task-b5-brief.md delete mode 100644 .superpowers/sdd/task-b5-report.md delete mode 100644 .superpowers/sdd/task-b5-review-package.txt delete mode 100644 .superpowers/sdd/task-b6-brief.md delete mode 100644 .superpowers/sdd/task-b6-report.md delete mode 100644 .superpowers/sdd/task-b6-review-package.txt diff --git a/.superpowers/sdd/final-phaseB-review-package.txt b/.superpowers/sdd/final-phaseB-review-package.txt deleted file mode 100644 index e30b19fb..00000000 --- a/.superpowers/sdd/final-phaseB-review-package.txt +++ /dev/null @@ -1,73 +0,0 @@ -=== BRANCH COMMITS (since e970f72) === -3b7ff9c docs(daemon): correct terminal-runtime comment to conpty on Windows -6a18394 feat(runtime): select conpty on Windows, register pty-host subcommand... -b9ef1f5 chore(sdd): B6 brief + B5 ledger -1511f1a style(ptyexec): replace em dashes carried from moved pty files -5bfbc48 feat(terminal): stream-based Attach for tmux/zellij/conpty -8d757ed chore(sdd): B5 brief + ledger -0bc26e5 fix(conpty): split IsAlive dead-vs-transient for reaper safety -4aac665 feat(conpty): add runtime adapter with loopback pty-client and sessio... -be06609 chore(sdd): B4 brief -6cd6e2a fix(conpty): deliver scrollback snapshot and register client atomically -a7b052b feat(conpty): add pty-host serve engine with loopback TCP transport (B3) -41ce417 chore(sdd): phase B briefs and progress for B1-B3 -6552e26 feat(ptyregistry): port Windows pty-host sideband registry to Go -d67941b test(conpty): harden copy-safety and add concurrent ring test -1050542 feat(conpty): add protocol codec and output ring buffer (pure Go, OS-... -926ee31 refactor(tmux): drop unused runner.Start seam -4b21e4b chore: tidy runtime-neutral comments and doctor import grouping -b459abf feat(runtime): wire tmux on Darwin/Linux via runtimeselect, keep zell... -44c3e61 fix(tmux): address four code-review findings in tmux runtime adapter -bc23964 feat(runtime): add tmux adapter package - -=== FULL STAT (backend) === - .../internal/adapters/runtime/conpty/host_main.go | 84 ++ - .../internal/adapters/runtime/conpty/host_test.go | 593 +++++++++++++ - .../adapters/runtime/conpty/pidalive_unix.go | 26 + - .../adapters/runtime/conpty/pidalive_windows.go | 30 + - backend/internal/adapters/runtime/conpty/proto.go | 89 ++ - .../internal/adapters/runtime/conpty/proto_test.go | 190 +++++ - .../runtime/conpty/ptyregistry/pidalive_unix.go | 19 + - .../runtime/conpty/ptyregistry/pidalive_windows.go | 19 + - .../runtime/conpty/ptyregistry/ptyregistry_test.go | 229 +++++ - .../runtime/conpty/ptyregistry/registry.go | 164 ++++ - backend/internal/adapters/runtime/conpty/ring.go | 83 ++ - .../internal/adapters/runtime/conpty/ring_test.go | 161 ++++ - .../internal/adapters/runtime/conpty/runtime.go | 234 +++++ - .../adapters/runtime/conpty/runtime_test.go | 676 +++++++++++++++ - backend/internal/adapters/runtime/conpty/spawn.go | 11 + - .../adapters/runtime/conpty/spawn_other.go | 16 + - .../adapters/runtime/conpty/spawn_windows.go | 121 +++ - .../runtime/ptyexec/spawn_unix.go} | 39 +- - .../runtime/ptyexec/spawn_unix_test.go} | 24 +- - .../runtime/ptyexec/spawn_windows.go} | 21 +- - .../runtime/ptyexec/spawn_windows_test.go} | 20 +- - .../runtime/runtimeselect/runtimeselect.go | 37 + - backend/internal/adapters/runtime/tmux/commands.go | 64 ++ - backend/internal/adapters/runtime/tmux/tmux.go | 488 +++++++++++ - .../adapters/runtime/tmux/tmux_integration_test.go | 143 ++++ - .../internal/adapters/runtime/tmux/tmux_test.go | 602 +++++++++++++ - .../internal/adapters/runtime/zellij/commands.go | 405 --------- - .../adapters/runtime/zellij/process_other.go | 18 - - .../adapters/runtime/zellij/process_windows.go | 79 -- - backend/internal/adapters/runtime/zellij/zellij.go | 950 --------------------- - .../runtime/zellij/zellij_integration_test.go | 165 ---- - .../adapters/runtime/zellij/zellij_test.go | 734 ---------------- - backend/internal/cli/doctor.go | 36 +- - backend/internal/cli/doctor_test.go | 38 +- - backend/internal/cli/ptyhost.go | 29 + - backend/internal/cli/root.go | 1 + - backend/internal/cli/spawn.go | 19 +- - backend/internal/daemon/daemon.go | 24 +- - backend/internal/daemon/lifecycle_wiring.go | 8 +- - backend/internal/daemon/wiring_test.go | 30 +- - backend/internal/httpd/terminal_mux_test.go | 13 +- - backend/internal/ports/outbound.go | 15 + - backend/internal/terminal/attachment.go | 98 +-- - .../terminal/attachment_integration_test.go | 61 +- - backend/internal/terminal/attachment_test.go | 112 +-- - backend/internal/terminal/fakes_test.go | 29 +- - backend/internal/terminal/logger_test.go | 4 +- - backend/internal/terminal/manager.go | 25 +- - backend/internal/terminal/manager_test.go | 54 +- - 55 files changed, 5296 insertions(+), 2706 deletions(-) diff --git a/.superpowers/sdd/final-review-package.txt b/.superpowers/sdd/final-review-package.txt deleted file mode 100644 index f015c844..00000000 --- a/.superpowers/sdd/final-review-package.txt +++ /dev/null @@ -1,2380 +0,0 @@ -=== COMMITS === -4b21e4b chore: tidy runtime-neutral comments and doctor import grouping -b459abf feat(runtime): wire tmux on Darwin/Linux via runtimeselect, keep zell... -44c3e61 fix(tmux): address four code-review findings in tmux runtime adapter -bc23964 feat(runtime): add tmux adapter package - -=== STAT (excluding sdd scratch) === -.../runtime/runtimeselect/runtimeselect.go | 46 ++ - backend/internal/adapters/runtime/tmux/commands.go | 64 +++ - backend/internal/adapters/runtime/tmux/tmux.go | 482 ++++++++++++++++ - .../adapters/runtime/tmux/tmux_integration_test.go | 143 +++++ - .../internal/adapters/runtime/tmux/tmux_test.go | 616 +++++++++++++++++++++ - backend/internal/cli/doctor.go | 30 +- - backend/internal/cli/doctor_test.go | 38 +- - backend/internal/cli/spawn.go | 21 +- - backend/internal/daemon/daemon.go | 24 +- - backend/internal/daemon/lifecycle_wiring.go | 6 +- - backend/internal/daemon/wiring_test.go | 7 +- - 11 files changed, 1427 insertions(+), 50 deletions(-) - -=== DIFF (backend only) === -.../runtime/runtimeselect/runtimeselect.go | 46 ++ - backend/internal/adapters/runtime/tmux/commands.go | 64 +++ - backend/internal/adapters/runtime/tmux/tmux.go | 482 ++++++++++++++++ - .../adapters/runtime/tmux/tmux_integration_test.go | 143 +++++ - .../internal/adapters/runtime/tmux/tmux_test.go | 616 +++++++++++++++++++++ - backend/internal/cli/doctor.go | 30 +- - backend/internal/cli/doctor_test.go | 38 +- - backend/internal/cli/spawn.go | 21 +- - backend/internal/daemon/daemon.go | 24 +- - backend/internal/daemon/lifecycle_wiring.go | 6 +- - backend/internal/daemon/wiring_test.go | 7 +- - 11 files changed, 1427 insertions(+), 50 deletions(-) - -diff --git a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go -new file mode 100644 -index 0000000..e6ca4a0 ---- /dev/null -+++ b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go -@@ -0,0 +1,46 @@ -+// Package runtimeselect picks the correct runtime backend by platform: -+// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). -+package runtimeselect -+ -+import ( -+ "context" -+ "log/slog" -+ "os" -+ "runtime" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" -+) -+ -+// Runtime is the union interface that both tmux and zellij satisfy. -+// It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods -+// the daemon wires directly. -+type Runtime interface { -+ ports.Runtime // Create, Destroy, IsAlive -+ SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error -+ GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) -+ AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) -+} -+ -+// Compile-time assertions: both adapters must implement the union interface. -+var _ Runtime = (*tmux.Runtime)(nil) -+var _ Runtime = (*zellij.Runtime)(nil) -+ -+// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. -+// log is used only for the Windows zellij socket-dir warning; nil is safe. -+func New(log *slog.Logger) Runtime { -+ if runtime.GOOS != "windows" { -+ return tmux.New(tmux.Options{}) -+ } -+ // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. -+ dir := zellij.DefaultSocketDir() -+ if dir != "" { -+ if err := os.MkdirAll(dir, 0o700); err != nil { -+ if log != nil { -+ log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) -+ } -+ } -+ } -+ return zellij.New(zellij.Options{SocketDir: dir}) -+} -diff --git a/backend/internal/adapters/runtime/tmux/commands.go b/backend/internal/adapters/runtime/tmux/commands.go -new file mode 100644 -index 0000000..1c2cf30 ---- /dev/null -+++ b/backend/internal/adapters/runtime/tmux/commands.go -@@ -0,0 +1,64 @@ -+package tmux -+ -+import "fmt" -+ -+// newSessionArgs builds args for `tmux new-session -d -s -x 220 -y 50 -+// -c -c `. The shell -c form runs the launch command -+// inside the configured shell so exported env vars and quoting work correctly. -+func newSessionArgs(id, cwd, shellPath, launchCmd string) []string { -+ return []string{ -+ "new-session", "-d", -+ "-s", id, -+ "-x", "220", -+ "-y", "50", -+ "-c", cwd, -+ shellPath, "-c", launchCmd, -+ } -+} -+ -+// setStatusOffArgs hides the tmux status bar for the given session. -+// set-option uses pane-targeting syntax which does not accept the `=` prefix, -+// so we pass the session name directly. -+func setStatusOffArgs(id string) []string { -+ return []string{"set-option", "-t", id, "status", "off"} -+} -+ -+// killSessionArgs builds args for `tmux kill-session -t =`. The `=` prefix -+// requests exact-name matching so a session "foo" does not accidentally match -+// "foobar" (tmux otherwise does unique-prefix matching). -+func killSessionArgs(id string) []string { -+ return []string{"kill-session", "-t", exactSessionTarget(id)} -+} -+ -+// hasSessionArgs builds args for `tmux has-session -t =`. The `=` prefix -+// requests exact-name matching (see killSessionArgs). -+func hasSessionArgs(id string) []string { -+ return []string{"has-session", "-t", exactSessionTarget(id)} -+} -+ -+// exactSessionTarget wraps id in tmux's exact-match prefix `=` so session- -+// selection commands (-t) target only the session with that precise name. -+// Only kill-session and has-session support this prefix; pane-targeting -+// commands (send-keys, capture-pane, set-option) use a plain session name. -+func exactSessionTarget(id string) string { -+ return "=" + id -+} -+ -+// sendKeysLiteralArgs builds args for `tmux send-keys -t -l `. -+// The -l flag stops tmux interpreting words like "Enter" as key names so the -+// text is sent verbatim. -+func sendKeysLiteralArgs(id, chunk string) []string { -+ return []string{"send-keys", "-t", id, "-l", chunk} -+} -+ -+// sendEnterArgs builds args for `tmux send-keys -t Enter` to submit the -+// queued input. -+func sendEnterArgs(id string) []string { -+ return []string{"send-keys", "-t", id, "Enter"} -+} -+ -+// capturePaneArgs builds args for `tmux capture-pane -t -p -S -`. -+// -p prints to stdout; -S - starts n lines back in history. -+func capturePaneArgs(id string, lines int) []string { -+ return []string{"capture-pane", "-t", id, "-p", "-S", fmt.Sprintf("-%d", lines)} -+} -diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go -new file mode 100644 -index 0000000..be6e2fd ---- /dev/null -+++ b/backend/internal/adapters/runtime/tmux/tmux.go -@@ -0,0 +1,482 @@ -+// Package tmux implements ports.Runtime using tmux sessions on Darwin/Linux. -+package tmux -+ -+import ( -+ "context" -+ "crypto/sha256" -+ "encoding/hex" -+ "errors" -+ "fmt" -+ "os" -+ "os/exec" -+ "regexp" -+ "sort" -+ "strings" -+ "time" -+ "unicode/utf8" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/domain" -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" -+) -+ -+const ( -+ defaultTimeout = 5 * time.Second -+ defaultChunkBytes = 16 * 1024 -+) -+ -+var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) -+ -+var getenv = os.Getenv -+ -+// Options configures a tmux Runtime. Every field has a sensible default (see -+// New), so the zero value is usable. -+type Options struct { -+ Binary string // default "tmux" (resolved via exec.LookPath) -+ Shell string // default $SHELL else /bin/sh -+ Timeout time.Duration // default 5s -+ ChunkSize int // default 16*1024 -+} -+ -+// Runtime runs agent sessions inside tmux sessions, driving them via the tmux -+// CLI. It implements ports.Runtime. -+type Runtime struct { -+ binary string -+ shell string -+ timeout time.Duration -+ chunkSize int -+ runner runner -+} -+ -+var _ ports.Runtime = (*Runtime)(nil) -+ -+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 = append(append([]string(nil), os.Environ()...), env...) -+ return cmd.CombinedOutput() -+} -+ -+func (execRunner) Start(env []string, name string, args ...string) error { -+ cmd := exec.Command(name, args...) -+ cmd.Env = append(append([]string(nil), os.Environ()...), env...) -+ return cmd.Start() -+} -+ -+// New builds a tmux Runtime, filling unset Options with defaults: binary "tmux" -+// (resolved via exec.LookPath), shell from $SHELL (else /bin/sh), and the -+// default timeout and output chunk size. -+func New(opts Options) *Runtime { -+ binary := opts.Binary -+ if binary == "" { -+ if path, err := exec.LookPath("tmux"); err == nil { -+ binary = path -+ } else { -+ binary = "tmux" -+ } -+ } -+ timeout := opts.Timeout -+ if timeout == 0 { -+ timeout = defaultTimeout -+ } -+ shellPath := opts.Shell -+ if shellPath == "" { -+ shellPath = getenv("SHELL") -+ } -+ if shellPath == "" { -+ shellPath = "/bin/sh" -+ } -+ chunkSize := opts.ChunkSize -+ if chunkSize <= 0 { -+ chunkSize = defaultChunkBytes -+ } -+ return &Runtime{ -+ binary: binary, -+ shell: shellPath, -+ timeout: timeout, -+ chunkSize: chunkSize, -+ runner: execRunner{}, -+ } -+} -+ -+// Create starts a new tmux session in the workspace, running the agent's -+// launch command with a keep-alive shell, and returns a handle to it. -+func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { -+ id, err := tmuxSessionName(cfg.SessionID) -+ if err != nil { -+ return ports.RuntimeHandle{}, err -+ } -+ if cfg.WorkspacePath == "" { -+ return ports.RuntimeHandle{}, errors.New("tmux runtime: workspace path is required") -+ } -+ if len(cfg.Argv) == 0 { -+ return ports.RuntimeHandle{}, errors.New("tmux runtime: launch command is required") -+ } -+ if err := validateEnvKeys(cfg.Env); err != nil { -+ return ports.RuntimeHandle{}, err -+ } -+ -+ launchCmd := buildLaunchCommand(cfg, r.shell) -+ args := newSessionArgs(id, cfg.WorkspacePath, r.shell, launchCmd) -+ if _, err := r.run(ctx, args...); err != nil { -+ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: create session %s: %w", id, err) -+ } -+ -+ // Hide the status bar in the embedded terminal: it clutters the view and -+ // was not designed for the in-browser display context. -+ if _, err := r.run(ctx, setStatusOffArgs(id)...); err != nil { -+ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) -+ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: set status %s: %w", id, err) -+ } -+ -+ handle := ports.RuntimeHandle{ID: id} -+ alive, err := r.IsAlive(ctx, handle) -+ if err != nil { -+ _ = r.Destroy(context.Background(), handle) -+ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: verify session %s: %w", id, err) -+ } -+ if !alive { -+ _ = r.Destroy(context.Background(), handle) -+ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: session %s exited before ready", id) -+ } -+ return handle, nil -+} -+ -+// Destroy kills the handle's tmux session. An already-gone session is treated -+// as success (idempotent). -+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, killSessionArgs(id)...) -+ if err != nil { -+ var exitErr *exec.ExitError -+ if errors.As(err, &exitErr) && killSessionMissingOutput(string(out)) { -+ return nil -+ } -+ return fmt.Errorf("tmux runtime: destroy session %s: %w", id, err) -+ } -+ return nil -+} -+ -+// IsAlive reports whether the handle's session still exists via `tmux -+// has-session`. Exit 0 means alive. A non-zero exit with output indicating the -+// session or server is missing is a definitive false, nil. Any other non-zero -+// exit is a probe error (not proof of death) so callers (the reaper feeding -+// the LCM) treat it as a failed probe and never kill a session on a transient -+// error. -+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, hasSessionArgs(id)...) -+ if err != nil { -+ var exitErr *exec.ExitError -+ if errors.As(err, &exitErr) && sessionMissingOutput(string(out)) { -+ return false, nil -+ } -+ return false, fmt.Errorf("tmux runtime: probe session %s: %w", id, err) -+ } -+ return true, nil -+} -+ -+// SendMessage sends literal text to the session (chunked via send-keys -l) then -+// presses Enter to submit. -+// -+// ponytail: send-keys -l chunked is simpler than load-buffer/paste-buffer; the -+// ceiling is very large messages may be slower, but chunk size defaults to 16 KB -+// which is ample for agent prompts. -+func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { -+ id, err := handleID(handle) -+ if err != nil { -+ return err -+ } -+ for _, chunk := range chunks(message, r.chunkSize) { -+ if _, err := r.run(ctx, sendKeysLiteralArgs(id, chunk)...); err != nil { -+ return fmt.Errorf("tmux runtime: send message %s: %w", id, err) -+ } -+ } -+ if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil { -+ return fmt.Errorf("tmux runtime: send enter %s: %w", id, err) -+ } -+ return nil -+} -+ -+// GetOutput returns the last `lines` lines of the session pane's captured -+// output. -+func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { -+ id, err := handleID(handle) -+ if err != nil { -+ return "", err -+ } -+ if lines <= 0 { -+ return "", errors.New("tmux runtime: lines must be positive") -+ } -+ out, err := r.run(ctx, capturePaneArgs(id, lines)...) -+ if err != nil { -+ return "", fmt.Errorf("tmux runtime: capture output %s: %w", id, err) -+ } -+ return tailLines(trimTrailingBlankLines(string(out)), lines), nil -+} -+ -+// AttachCommand returns the argv a human runs to attach their terminal to the -+// session. No per-session env block is needed for tmux (unlike zellij's Windows -+// ConPTY path), so env is always nil. -+func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { -+ id, err := handleID(handle) -+ if err != nil { -+ return nil, nil, err -+ } -+ return []string{r.binary, "attach-session", "-t", id}, nil, nil -+} -+ -+// run wraps runner.Run with a per-call timeout context. -+func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { -+ cmdCtx, cancel := context.WithTimeout(ctx, r.timeout) -+ defer cancel() -+ out, err := r.runner.Run(cmdCtx, nil, r.binary, args...) -+ if cmdCtx.Err() != nil { -+ return out, cmdCtx.Err() -+ } -+ if err != nil { -+ return out, commandError{err: err, output: strings.TrimSpace(string(out))} -+ } -+ return out, nil -+} -+ -+// -- session name helpers -- -+ -+func tmuxSessionName(id domain.SessionID) (string, error) { -+ raw := string(id) -+ if raw == "" { -+ return "", errors.New("tmux runtime: session id is required") -+ } -+ return SessionName(raw), nil -+} -+ -+// SessionName returns the tmux session name the runtime registers for a given -+// session id, applying the same sanitisation Create does. Callers that print an -+// attach hint must use this rather than the raw id. -+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 handleID(handle ports.RuntimeHandle) (string, error) { -+ id := handle.ID -+ if id == "" { -+ return "", errors.New("tmux runtime: session id is required") -+ } -+ if !sessionIDPattern.MatchString(id) { -+ return "", fmt.Errorf("tmux runtime: invalid handle id %q", id) -+ } -+ return id, nil -+} -+ -+// -- output detection helpers -- -+ -+// sessionMissingOutput reports whether a non-zero `tmux has-session` or -+// `tmux kill-session` exit is definitively "session does not exist" rather -+// than a transient probe failure. -+func sessionMissingOutput(out string) bool { -+ s := strings.ToLower(out) -+ return strings.Contains(s, "can't find session") || -+ strings.Contains(s, "no server running") || -+ strings.Contains(s, "error connecting") || -+ strings.Contains(s, "session not found") -+} -+ -+// killSessionMissingOutput reports whether a non-zero `tmux kill-session` -+// failed because the session was already gone. -+func killSessionMissingOutput(out string) bool { -+ return sessionMissingOutput(out) -+} -+ -+// -- text helpers (ported from zellij) -- -+ -+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, "") -+} -+ -+// -- env / quoting helpers -- -+ -+func validateEnvKeys(env map[string]string) error { -+ for key := range env { -+ if !validEnvKey(key) { -+ return fmt.Errorf("tmux runtime: invalid env key %q", key) -+ } -+ } -+ return nil -+} -+ -+func validEnvKey(key string) bool { -+ if key == "" { -+ return false -+ } -+ for i, r := range key { -+ if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { -+ continue -+ } -+ if i > 0 && r >= '0' && r <= '9' { -+ continue -+ } -+ return false -+ } -+ return true -+} -+ -+func sortedKeys(m map[string]string) []string { -+ keys := make([]string, 0, len(m)) -+ for k := range m { -+ keys = append(keys, k) -+ } -+ sort.Strings(keys) -+ return keys -+} -+ -+func shellQuote(s string) string { -+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" -+} -+ -+// buildLaunchCommand builds the shell command string passed to `sh -c` (or the -+// configured shell). It exports env vars, then runs argv, then execs a -+// keep-alive interactive shell so the tmux session survives the agent exiting. -+// -+// ponytail: PATH handling matches the zellij unix path: PATH from cfg.Env is -+// exported last, after all other keys, so an explicit override takes effect. -+func buildLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { -+ path := cfg.Env["PATH"] -+ if path == "" { -+ path = getenv("PATH") -+ } -+ -+ var b strings.Builder -+ for _, key := range sortedKeys(cfg.Env) { -+ if key == "PATH" { -+ continue -+ } -+ b.WriteString("export ") -+ b.WriteString(key) -+ b.WriteString("=") -+ b.WriteString(shellQuote(cfg.Env[key])) -+ b.WriteString("; ") -+ } -+ if path != "" { -+ b.WriteString("export PATH=") -+ b.WriteString(shellQuote(path)) -+ b.WriteString("; ") -+ } -+ // Quote each argv word so spaces inside a word are preserved. -+ parts := make([]string, len(cfg.Argv)) -+ for i, a := range cfg.Argv { -+ parts[i] = shellQuote(a) -+ } -+ b.WriteString(strings.Join(parts, " ")) -+ // Keep the tmux session alive after the agent exits so the operator can -+ // inspect the terminal. The shell variable expansion picks up $SHELL from -+ // the process env if set, otherwise falls back to /bin/sh. -+ b.WriteString(`; exec "${SHELL:-/bin/sh}" -i`) -+ return b.String() -+} -+ -+// -- error type -- -+ -+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 } -diff --git a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go -new file mode 100644 -index 0000000..1f491f8 ---- /dev/null -+++ b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go -@@ -0,0 +1,143 @@ -+package tmux -+ -+import ( -+ "context" -+ "os/exec" -+ "strings" -+ "testing" -+ "time" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/domain" -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" -+) -+ -+func TestRuntimeIntegration(t *testing.T) { -+ if _, err := exec.LookPath("tmux"); err != nil { -+ t.Skip("tmux unavailable") -+ } -+ -+ ctx := context.Background() -+ id := strings.ReplaceAll(t.Name(), "/", "_") -+ r := New(Options{Timeout: 5 * time.Second}) -+ -+ // Ensure clean slate: ignore errors (session may not exist). -+ _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) -+ -+ t.Cleanup(func() { -+ // Always destroy so a test failure never leaks a tmux session. -+ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) -+ }) -+ -+ h, err := r.Create(ctx, ports.RuntimeConfig{ -+ SessionID: domain.SessionID(id), -+ WorkspacePath: t.TempDir(), -+ // Run a trivial command then drop into an interactive shell (the keep-alive -+ // exec is added by buildLaunchCommand, but we also verify here that output -+ // appears). -+ Argv: []string{"sh", "-c", "echo hello-from-tmux"}, -+ Env: map[string]string{"AO_SESSION_ID": id}, -+ }) -+ if err != nil { -+ t.Fatalf("Create: %v", err) -+ } -+ -+ alive, err := r.IsAlive(ctx, h) -+ if err != nil { -+ t.Fatalf("IsAlive: %v", err) -+ } -+ if !alive { -+ t.Fatal("alive = false, want true after create") -+ } -+ -+ // Wait for the echo output to appear (the session may take a moment to -+ // write it to the pane history). -+ out := waitForOutput(t, r, h, "hello-from-tmux", 5*time.Second) -+ if !strings.Contains(out, "hello-from-tmux") { -+ t.Fatalf("output = %q, want hello-from-tmux", out) -+ } -+ -+ // Send a command and verify it echoes back. -+ if err := r.SendMessage(ctx, h, "echo hello-send"); err != nil { -+ t.Fatalf("SendMessage: %v", err) -+ } -+ out = waitForOutput(t, r, h, "hello-send", 5*time.Second) -+ if !strings.Contains(out, "hello-send") { -+ t.Fatalf("output after SendMessage = %q, want hello-send", out) -+ } -+ -+ // Destroy and verify liveness goes false. -+ if err := r.Destroy(ctx, h); err != nil { -+ t.Fatalf("Destroy: %v", err) -+ } -+ alive, err = r.IsAlive(ctx, h) -+ if err != nil { -+ t.Fatalf("IsAlive after destroy: %v", err) -+ } -+ if alive { -+ t.Fatal("alive after destroy = true, want false") -+ } -+} -+ -+// TestRuntimeIntegrationExactSessionParsing verifies that IsAlive uses exact -+// session matching and does not treat a prefix as a live session. -+func TestRuntimeIntegrationExactSessionParsing(t *testing.T) { -+ if _, err := exec.LookPath("tmux"); err != nil { -+ t.Skip("tmux unavailable") -+ } -+ -+ ctx := context.Background() -+ base := strings.ReplaceAll(t.Name(), "/", "_") -+ longID := base + "_long" -+ prefixID := base -+ -+ r := New(Options{Timeout: 5 * time.Second}) -+ _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) -+ _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID}) -+ -+ t.Cleanup(func() { -+ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: longID}) -+ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: prefixID}) -+ }) -+ -+ h, err := r.Create(ctx, ports.RuntimeConfig{ -+ SessionID: domain.SessionID(longID), -+ WorkspacePath: t.TempDir(), -+ Argv: []string{"sh", "-c", "echo ready"}, -+ }) -+ if err != nil { -+ t.Fatalf("Create: %v", err) -+ } -+ -+ // tmux has-session -t should NOT match because tmux -+ // requires the exact session name when using -t with a plain string (not a -+ // glob). Verify by probing the prefix handle directly. -+ prefixAlive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID}) -+ if err != nil { -+ // tmux may return an error (session not found) rather than exit 0. -+ // That is acceptable here: the point is the prefix must not be alive. -+ t.Logf("IsAlive prefix returned error (acceptable): %v", err) -+ } -+ if prefixAlive { -+ _ = r.Destroy(ctx, h) -+ t.Fatal("prefix handle reported alive; tmux session matching is not exact") -+ } -+} -+ -+// waitForOutput polls GetOutput until out contains want or the deadline passes. -+func waitForOutput(t *testing.T, r *Runtime, h ports.RuntimeHandle, want string, deadline time.Duration) string { -+ t.Helper() -+ end := time.Now().Add(deadline) -+ var out string -+ for time.Now().Before(end) { -+ var err error -+ out, err = r.GetOutput(context.Background(), h, 50) -+ if err != nil { -+ t.Fatalf("GetOutput: %v", err) -+ } -+ if strings.Contains(out, want) { -+ return out -+ } -+ time.Sleep(100 * time.Millisecond) -+ } -+ return out -+} -diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go -new file mode 100644 -index 0000000..72b165b ---- /dev/null -+++ b/backend/internal/adapters/runtime/tmux/tmux_test.go -@@ -0,0 +1,616 @@ -+package tmux -+ -+import ( -+ "context" -+ "errors" -+ "os/exec" -+ "reflect" -+ "strings" -+ "testing" -+ "time" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/domain" -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" -+) -+ -+// -- fakeRunner test seam (mirrors zellij_test.go exactly) -- -+ -+type fakeRunner struct { -+ calls []runnerCall -+ outputs [][]byte -+ err error -+} -+ -+type runnerCall struct { -+ env []string -+ name string -+ args []string -+} -+ -+func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { -+ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) -+ var out []byte -+ if len(f.outputs) > 0 { -+ out = f.outputs[0] -+ f.outputs = f.outputs[1:] -+ } -+ if f.err != nil { -+ return out, f.err -+ } -+ return out, nil -+} -+ -+func (f *fakeRunner) Start(env []string, name string, args ...string) error { -+ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) -+ if len(f.outputs) > 0 { -+ f.outputs = f.outputs[1:] -+ } -+ return f.err -+} -+ -+// -- helpers -- -+ -+func newTestRuntime(chunkSize int) (*Runtime, *fakeRunner) { -+ fr := &fakeRunner{} -+ r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/sh", ChunkSize: chunkSize}) -+ r.runner = fr -+ return r, fr -+} -+ -+// -- Options / New tests -- -+ -+func TestNewDefaultsToPortableShell(t *testing.T) { -+ t.Setenv("SHELL", "") -+ r := New(Options{}) -+ if got := r.shell; got != "/bin/sh" { -+ t.Fatalf("default shell = %q, want /bin/sh", got) -+ } -+} -+ -+func TestNewPicksUpShellFromEnv(t *testing.T) { -+ t.Setenv("SHELL", "/bin/zsh") -+ r := New(Options{}) -+ if got := r.shell; got != "/bin/zsh" { -+ t.Fatalf("shell = %q, want /bin/zsh", got) -+ } -+} -+ -+// -- command builder tests -- -+ -+func TestCommandBuilders(t *testing.T) { -+ if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/sh", `echo hi; exec "${SHELL:-/bin/sh}" -i`), -+ []string{"new-session", "-d", "-s", "sess-1", "-x", "220", "-y", "50", "-c", "/tmp/ws", "/bin/sh", "-c", `echo hi; exec "${SHELL:-/bin/sh}" -i`}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("newSessionArgs = %#v, want %#v", got, want) -+ } -+ // set-option uses pane-targeting (no = prefix). -+ if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "sess-1", "status", "off"}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want) -+ } -+ // kill-session and has-session use exact-match prefix =. -+ if got, want := killSessionArgs("sess-1"), []string{"kill-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("killSessionArgs = %#v, want %#v", got, want) -+ } -+ if got, want := hasSessionArgs("sess-1"), []string{"has-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("hasSessionArgs = %#v, want %#v", got, want) -+ } -+ if got, want := sendKeysLiteralArgs("sess-1", "hello"), []string{"send-keys", "-t", "sess-1", "-l", "hello"}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("sendKeysLiteralArgs = %#v, want %#v", got, want) -+ } -+ if got, want := sendEnterArgs("sess-1"), []string{"send-keys", "-t", "sess-1", "Enter"}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("sendEnterArgs = %#v, want %#v", got, want) -+ } -+ if got, want := capturePaneArgs("sess-1", 10), []string{"capture-pane", "-t", "sess-1", "-p", "-S", "-10"}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("capturePaneArgs = %#v, want %#v", got, want) -+ } -+} -+ -+// -- session name sanitization -- -+ -+func TestSessionNameSanitizesSpecialChars(t *testing.T) { -+ got, err := tmuxSessionName("repo/issue#42.1") -+ if err != nil { -+ t.Fatalf("tmuxSessionName: %v", err) -+ } -+ if !sessionIDPattern.MatchString(got) { -+ t.Fatalf("sanitized id %q fails pattern", got) -+ } -+ if !strings.HasPrefix(got, "repo-issue-42-1-") { -+ t.Fatalf("sanitized id = %q, want readable prefix", got) -+ } -+ if got == "repo/issue#42.1" { -+ t.Fatal("sanitized id still contains raw unsafe characters") -+ } -+} -+ -+func TestSessionNamePassesThroughShortConforming(t *testing.T) { -+ if got := SessionName("myproj-1"); got != "myproj-1" { -+ t.Fatalf("SessionName = %q, want unchanged", got) -+ } -+} -+ -+func TestSessionNameMatchesCreateNaming(t *testing.T) { -+ long := domain.SessionID(strings.Repeat("x", 60) + "-1") -+ viaCreate, err := tmuxSessionName(long) -+ if err != nil { -+ t.Fatalf("tmuxSessionName: %v", err) -+ } -+ if got := SessionName(string(long)); got != viaCreate { -+ t.Fatalf("SessionName = %q, but Create uses %q", got, viaCreate) -+ } -+ if SessionName(string(long)) == string(long) { -+ t.Fatal("expected long id to be sanitised to a different name") -+ } -+} -+ -+// -- env key validation -- -+ -+func TestCreateRejectsInvalidEnvKeys(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ _ = fr -+ _, err := r.Create(context.Background(), ports.RuntimeConfig{ -+ SessionID: "sess-1", -+ WorkspacePath: "/tmp/ws", -+ Argv: []string{"echo", "hi"}, -+ Env: map[string]string{"BAD KEY": "x"}, -+ }) -+ if err == nil || !strings.Contains(err.Error(), "invalid env key") { -+ t.Fatalf("Create err = %v, want invalid env key", err) -+ } -+} -+ -+// -- Create tests -- -+ -+func TestCreateIssuesNewSessionAndStatusOff(t *testing.T) { -+ // new-session output (ignored), set-option output, has-session output (exit 0 = alive) -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{nil, nil, nil} -+ -+ h, err := r.Create(context.Background(), ports.RuntimeConfig{ -+ SessionID: "sess-1", -+ WorkspacePath: "/tmp/ws", -+ Argv: []string{"echo", "hi"}, -+ Env: map[string]string{"AO_SESSION_ID": "sess-1"}, -+ }) -+ if err != nil { -+ t.Fatalf("Create: %v", err) -+ } -+ if h.ID != "sess-1" { -+ t.Fatalf("handle ID = %q, want sess-1", h.ID) -+ } -+ // Expect 3 calls: new-session, set-option, has-session. -+ if len(fr.calls) != 3 { -+ t.Fatalf("calls = %d, want 3", len(fr.calls)) -+ } -+ -+ // Call 0: new-session -+ if got := fr.calls[0].args[0]; got != "new-session" { -+ t.Fatalf("call[0] = %q, want new-session", got) -+ } -+ // Check -s , -c are present. -+ joined := strings.Join(fr.calls[0].args, " ") -+ if !strings.Contains(joined, "-s sess-1") { -+ t.Fatalf("new-session args missing -s sess-1: %v", fr.calls[0].args) -+ } -+ if !strings.Contains(joined, "-c /tmp/ws") { -+ t.Fatalf("new-session args missing -c /tmp/ws: %v", fr.calls[0].args) -+ } -+ // Ensure -x and -y are set. -+ if !strings.Contains(joined, "-x 220") || !strings.Contains(joined, "-y 50") { -+ t.Fatalf("new-session args missing -x/-y: %v", fr.calls[0].args) -+ } -+ -+ // Call 1: set-option status off (plain target, pane-targeting does not use =). -+ if got, want := fr.calls[1].args, setStatusOffArgs("sess-1"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("call[1] = %#v, want %#v", got, want) -+ } -+ -+ // Call 2: has-session (IsAlive, uses exact-match target =sess-1). -+ if got, want := fr.calls[2].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("call[2] = %#v, want %#v", got, want) -+ } -+} -+ -+func TestCreateLaunchCommandContainsKeepAliveShell(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{nil, nil, nil} -+ -+ _, err := r.Create(context.Background(), ports.RuntimeConfig{ -+ SessionID: "sess-1", -+ WorkspacePath: "/tmp/ws", -+ Argv: []string{"myagent", "--flag"}, -+ }) -+ if err != nil { -+ t.Fatalf("Create: %v", err) -+ } -+ // The launch command is the last argument to new-session (after shellPath -c). -+ args := fr.calls[0].args -+ launchCmd := args[len(args)-1] -+ if !strings.Contains(launchCmd, `exec "${SHELL:-/bin/sh}" -i`) { -+ t.Fatalf("launch command missing keep-alive shell: %q", launchCmd) -+ } -+ if !strings.Contains(launchCmd, "'myagent'") { -+ t.Fatalf("launch command missing quoted argv: %q", launchCmd) -+ } -+} -+ -+func TestCreateLaunchCommandExportsEnvVars(t *testing.T) { -+ oldGetenv := getenv -+ getenv = func(key string) string { -+ if key == "PATH" { -+ return "/usr/bin:/bin" -+ } -+ return "" -+ } -+ defer func() { getenv = oldGetenv }() -+ -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{nil, nil, nil} -+ -+ _, err := r.Create(context.Background(), ports.RuntimeConfig{ -+ SessionID: "sess-1", -+ WorkspacePath: "/tmp/ws", -+ Argv: []string{"myagent"}, -+ Env: map[string]string{ -+ "AO_SESSION_ID": "sess-1", -+ "ODD": "can't", -+ "PATH": "/custom/bin:/usr/bin", -+ }, -+ }) -+ if err != nil { -+ t.Fatalf("Create: %v", err) -+ } -+ args := fr.calls[0].args -+ launchCmd := args[len(args)-1] -+ for _, want := range []string{ -+ "export AO_SESSION_ID='sess-1';", -+ "export ODD='can'\\''t';", -+ "export PATH='/custom/bin:/usr/bin';", -+ } { -+ if !strings.Contains(launchCmd, want) { -+ t.Fatalf("launch command missing %q in: %q", want, launchCmd) -+ } -+ } -+} -+ -+func TestCreateDestroysAndReturnsErrorWhenNotAlive(t *testing.T) { -+ // Use a specialized fakeRunner that returns an exit error only for the 3rd call. -+ r2, _ := newTestRuntime(0) -+ fr3 := &fakeRunnerSelectiveErr{exitErrAt: 2} -+ fr3.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} -+ r2.runner = fr3 -+ -+ _, err := r2.Create(context.Background(), ports.RuntimeConfig{ -+ SessionID: "sess-1", -+ WorkspacePath: "/tmp/ws", -+ Argv: []string{"myagent"}, -+ }) -+ if err == nil { -+ t.Fatal("Create: got nil, want error when session not alive after create") -+ } -+ // Verify Destroy was called (kill-session). -+ hasKill := false -+ for _, c := range fr3.calls { -+ if len(c.args) > 0 && c.args[0] == "kill-session" { -+ hasKill = true -+ } -+ } -+ if !hasKill { -+ t.Fatal("expected kill-session cleanup call when session not alive") -+ } -+} -+ -+// fakeRunnerSelectiveErr returns an exec.ExitError for the call at index exitErrAt. -+type fakeRunnerSelectiveErr struct { -+ calls []runnerCall -+ outputs [][]byte -+ exitErrAt int -+} -+ -+func (f *fakeRunnerSelectiveErr) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { -+ idx := len(f.calls) -+ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) -+ var out []byte -+ if len(f.outputs) > 0 { -+ out = f.outputs[0] -+ f.outputs = f.outputs[1:] -+ } -+ if idx == f.exitErrAt { -+ return out, &exec.ExitError{} -+ } -+ return out, nil -+} -+ -+func (f *fakeRunnerSelectiveErr) Start(env []string, name string, args ...string) error { -+ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) -+ if len(f.outputs) > 0 { -+ f.outputs = f.outputs[1:] -+ } -+ return nil -+} -+ -+// -- Destroy tests -- -+ -+func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("can't find session: sess-1")} -+ fr.err = &exec.ExitError{} -+ -+ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { -+ t.Fatalf("Destroy: %v", err) -+ } -+ if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" { -+ t.Fatalf("calls = %#v, want only kill-session", fr.calls) -+ } -+} -+ -+func TestDestroyIsIdempotentWhenNoServer(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("no server running on /tmp/tmux-1000/default")} -+ fr.err = &exec.ExitError{} -+ -+ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { -+ t.Fatalf("Destroy no-server: %v", err) -+ } -+} -+ -+func TestDestroyReportsUnexpectedFailures(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("permission denied")} -+ fr.err = &exec.ExitError{} -+ -+ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err == nil { -+ t.Fatal("Destroy: got nil, want unexpected failure error") -+ } -+} -+ -+func TestDestroyArgs(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{nil} -+ -+ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { -+ t.Fatalf("Destroy: %v", err) -+ } -+ // killSessionArgs uses exact-match target =. -+ if got, want := fr.calls[0].args, killSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("destroy args = %#v, want %#v", got, want) -+ } -+} -+ -+// -- IsAlive tests -- -+ -+func TestIsAliveReturnsTrueOnExitZero(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{nil} -+ -+ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) -+ if err != nil { -+ t.Fatalf("IsAlive: %v", err) -+ } -+ if !alive { -+ t.Fatal("alive = false, want true") -+ } -+ if got, want := fr.calls[0].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("has-session args = %#v, want %#v", got, want) -+ } -+} -+ -+func TestIsAliveReturnsFalseNilOnCantFindSession(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("can't find session: sess-1")} -+ fr.err = &exec.ExitError{} -+ -+ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) -+ if err != nil { -+ t.Fatalf("IsAlive: %v", err) -+ } -+ if alive { -+ t.Fatal("alive = true, want false") -+ } -+} -+ -+func TestIsAliveReturnsFalseNilOnNoServer(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("no server running on /tmp/tmux-1000/default")} -+ fr.err = &exec.ExitError{} -+ -+ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) -+ if err != nil { -+ t.Fatalf("IsAlive: %v", err) -+ } -+ if alive { -+ t.Fatal("alive = true, want false") -+ } -+} -+ -+func TestIsAliveReturnsFalseNilOnErrorConnecting(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("error connecting to /tmp/tmux-1000/default (No such file or directory)")} -+ fr.err = &exec.ExitError{} -+ -+ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) -+ if err != nil { -+ t.Fatalf("IsAlive error connecting: %v", err) -+ } -+ if alive { -+ t.Fatal("alive = true, want false") -+ } -+} -+ -+// IsAlive must treat any non-"missing" non-zero exit as a probe error so the -+// reaper never reads a transient failure as proof of death. -+func TestIsAliveReportsOtherExitFailuresAsProbeErrors(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("unexpected internal error")} -+ fr.err = &exec.ExitError{} -+ -+ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) -+ if err == nil { -+ t.Fatal("IsAlive: got nil, want probe error; failed probe must not read as dead") -+ } -+ if alive { -+ t.Fatal("alive = true on probe failure") -+ } -+} -+ -+// -- SendMessage tests -- -+ -+func TestSendMessageChunksAndSendsEnter(t *testing.T) { -+ r, fr := newTestRuntime(5) // chunkSize=5 -+ // "hello世界": hello=5 bytes, 世=3 bytes, 界=3 bytes => 3 sends + 1 Enter -+ if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, "hello世界"); err != nil { -+ t.Fatalf("SendMessage: %v", err) -+ } -+ if len(fr.calls) != 4 { -+ t.Fatalf("calls = %d, want 4 (3 chunks + Enter)", len(fr.calls)) -+ } -+ if got, want := fr.calls[0].args, sendKeysLiteralArgs("sess-1", "hello"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("chunk 1 args = %#v, want %#v", got, want) -+ } -+ if got, want := fr.calls[1].args, sendKeysLiteralArgs("sess-1", "世"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("chunk 2 args = %#v, want %#v", got, want) -+ } -+ if got, want := fr.calls[2].args, sendKeysLiteralArgs("sess-1", "界"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("chunk 3 args = %#v, want %#v", got, want) -+ } -+ if got, want := fr.calls[3].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("Enter args = %#v, want %#v", got, want) -+ } -+} -+ -+func TestSendMessageUsesLiteralFlag(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, "Enter"); err != nil { -+ t.Fatalf("SendMessage: %v", err) -+ } -+ // First call must use -l so "Enter" is sent literally, not as a key binding. -+ if fr.calls[0].args[3] != "-l" { -+ t.Fatalf("send-keys args[3] = %q, want -l", fr.calls[0].args[3]) -+ } -+} -+ -+// -- GetOutput tests -- -+ -+func TestGetOutputValidatesLines(t *testing.T) { -+ r, _ := newTestRuntime(0) -+ _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 0) -+ if err == nil { -+ t.Fatal("GetOutput lines=0: got nil, want error") -+ } -+} -+ -+func TestGetOutputTrimsLines(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("one\ntwo\nthree\n")} -+ -+ out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 2) -+ if err != nil { -+ t.Fatalf("GetOutput: %v", err) -+ } -+ if out != "two\nthree\n" { -+ t.Fatalf("output = %q, want last two lines", out) -+ } -+} -+ -+func TestGetOutputTrimsTrailingScreenPaddingBeforeTailing(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("ready\nprompt> echo hi\nhi\n\n\n\n")} -+ -+ out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 2) -+ if err != nil { -+ t.Fatalf("GetOutput: %v", err) -+ } -+ if out != "prompt> echo hi\nhi\n" { -+ t.Fatalf("output = %q, want last non-padding lines", out) -+ } -+} -+ -+func TestGetOutputArgs(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("output\n")} -+ -+ _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 10) -+ if err != nil { -+ t.Fatalf("GetOutput: %v", err) -+ } -+ if got, want := fr.calls[0].args, capturePaneArgs("sess-1", 10); !reflect.DeepEqual(got, want) { -+ t.Fatalf("capture-pane args = %#v, want %#v", got, want) -+ } -+} -+ -+// -- AttachCommand tests -- -+ -+func TestAttachCommandReturnsExpectedArgv(t *testing.T) { -+ r := New(Options{Binary: "/usr/bin/tmux", Timeout: time.Second}) -+ argv, env, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1"}) -+ if err != nil { -+ t.Fatalf("AttachCommand: %v", err) -+ } -+ want := []string{"/usr/bin/tmux", "attach-session", "-t", "sess-1"} -+ if !reflect.DeepEqual(argv, want) { -+ t.Fatalf("argv = %#v, want %#v", argv, want) -+ } -+ if env != nil { -+ t.Fatalf("env = %#v, want nil", env) -+ } -+} -+ -+func TestAttachCommandRejectsInvalidHandle(t *testing.T) { -+ r := New(Options{}) -+ _, _, err := r.AttachCommand(ports.RuntimeHandle{ID: ""}) -+ if err == nil { -+ t.Fatal("AttachCommand empty handle: got nil, want error") -+ } -+} -+ -+// -- commandError tests -- -+ -+func TestCommandErrorUnwraps(t *testing.T) { -+ base := errors.New("base") -+ err := commandError{err: base, output: "details"} -+ if !errors.Is(err, base) { -+ t.Fatal("commandError should unwrap base error") -+ } -+ if !strings.Contains(err.Error(), "details") { -+ t.Fatalf("error = %q, want output details", err.Error()) -+ } -+} -+ -+// -- text helper tests -- -+ -+func TestChunks(t *testing.T) { -+ if got := chunks("", 5); !reflect.DeepEqual(got, []string{""}){ -+ t.Fatalf("chunks empty = %#v", got) -+ } -+ if got := chunks("hello", 10); !reflect.DeepEqual(got, []string{"hello"}) { -+ t.Fatalf("chunks fits = %#v", got) -+ } -+ // UTF-8 boundary: 世 is 3 bytes; with chunkSize=5 "hello世界" splits at 5,6,6 -+ got := chunks("hello世界", 5) -+ if len(got) != 3 { -+ t.Fatalf("chunks count = %d, want 3: %#v", len(got), got) -+ } -+ if got[0] != "hello" || got[1] != "世" || got[2] != "界" { -+ t.Fatalf("chunks = %#v, want [hello 世 界]", got) -+ } -+} -+ -+func TestTailLines(t *testing.T) { -+ if got := tailLines("a\nb\nc\n", 2); got != "b\nc\n" { -+ t.Fatalf("tailLines = %q, want b/c", got) -+ } -+ if got := tailLines("a\nb\n", 5); got != "a\nb\n" { -+ t.Fatalf("tailLines fewer = %q", got) -+ } -+ if got := tailLines("", 5); got != "" { -+ t.Fatalf("tailLines empty = %q", got) -+ } -+} -+ -+func TestTrimTrailingBlankLines(t *testing.T) { -+ if got := trimTrailingBlankLines("a\nb\n\n\n"); got != "a\nb\n" { -+ t.Fatalf("trimTrailingBlankLines = %q, want a/b", got) -+ } -+ if got := trimTrailingBlankLines(""); got != "" { -+ t.Fatalf("trimTrailingBlankLines empty = %q", got) -+ } -+} -diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go -index 60b0e29..a52fa52 100644 ---- a/backend/internal/cli/doctor.go -+++ b/backend/internal/cli/doctor.go -@@ -4,20 +4,21 @@ import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "net/http" - "os" - "path/filepath" - "regexp" -+ "runtime" - "strconv" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - ) -@@ -161,21 +162,21 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { - msg = fmt.Sprintf("%s pid=%d port=%d", msg, st.PID, st.Port) - } - if st.Error != "" { - msg += " (" + st.Error + ")" - } - checks = append(checks, doctorCheck{Level: level, Section: doctorSectionCore, Name: "daemon", Message: msg}) - } - - checks = append(checks, - c.checkGit(ctx), -- c.checkZellij(ctx), -+ c.checkTerminalRuntime(ctx), - c.checkAOBinary(), - ) - for _, harness := range doctorHarnesses { - checks = append(checks, c.checkHarness(ctx, harness)) - } - checks = append(checks, c.checkCodexLaunchFlags(ctx), c.checkGitHubToken(ctx)) - return checks - } - - // checkStore inspects the SQLite store WITHOUT opening or migrating it. The -@@ -283,20 +284,47 @@ func (c *commandContext) checkGit(ctx context.Context) doctorCheck { - cmp, err := compareDottedVersion(version, minGitVersion) - if err != nil { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version unknown: %s)", path, firstOutputLine(out))} - } - if cmp < 0 { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; AO expects >= %s for worktrees)", path, version, minGitVersion)} - } - return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; supports worktrees)", path, version)} - } - -+// checkTerminalRuntime checks for the runtime multiplexer used on this platform: -+// tmux on Darwin/Linux, zellij on Windows. -+func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { -+ if runtime.GOOS == "windows" { -+ return c.checkZellij(ctx) -+ } -+ return c.checkTmux(ctx) -+} -+ -+func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { -+ path, err := c.deps.LookPath("tmux") -+ if err != nil || path == "" { -+ return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"} -+ } -+ reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) -+ defer cancel() -+ out, err := c.deps.CommandOutput(reqCtx, path, "-V") -+ if err != nil { -+ return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s: %v", path, err)} -+ } -+ version := firstOutputLine(out) -+ if version == "" { -+ version = "version unknown" -+ } -+ return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s)", path, version)} -+} -+ - func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { - path, err := c.deps.LookPath("zellij") - if err != nil || path == "" { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "zellij", Message: "not found in PATH"} - } - reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) - defer cancel() - out, err := c.deps.CommandOutput(reqCtx, path, "--version") - if err != nil { - return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} -diff --git a/backend/internal/cli/doctor_test.go b/backend/internal/cli/doctor_test.go -index c1bf169..2e4acf0 100644 ---- a/backend/internal/cli/doctor_test.go -+++ b/backend/internal/cli/doctor_test.go -@@ -45,67 +45,69 @@ func TestDoctorWarnsOnUnsupportedGitVersion(t *testing.T) { - func TestDoctorFailsWhenGitMissing(t *testing.T) { - setConfigEnv(t) - c := doctorContext(t, map[string]string{}, nil) - - check := findDoctorCheck(t, c.runDoctor(context.Background()), "git") - if check.Level != doctorFail { - t.Fatalf("git check = %+v, want FAIL", check) - } - } - --func TestDoctorChecksZellijVersion(t *testing.T) { -+func TestDoctorChecksTmuxVersion(t *testing.T) { - setConfigEnv(t) -- c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, args ...string) ([]byte, error) { -+ c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, args ...string) ([]byte, error) { - switch name { - case "/bin/git": - return []byte("git version 2.43.0\n"), nil -- case "/bin/zellij": -- if len(args) != 1 || args[0] != "--version" { -- t.Fatalf("unexpected zellij command: %s %v", name, args) -+ case "/bin/tmux": -+ if len(args) != 1 || args[0] != "-V" { -+ t.Fatalf("unexpected tmux command: %s %v", name, args) - } -- return []byte("zellij 0.44.3\n"), nil -+ return []byte("tmux 3.3a\n"), nil - default: - t.Fatalf("unexpected command: %s %v", name, args) - return nil, nil - } - }) - -- check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") -- if check.Level != doctorPass || !strings.Contains(check.Message, "0.44.3") { -- t.Fatalf("zellij check = %+v, want PASS with version", check) -+ check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") -+ if check.Level != doctorPass || !strings.Contains(check.Message, "3.3a") { -+ t.Fatalf("tmux check = %+v, want PASS with version", check) - } - } - --func TestDoctorFailsUnsupportedZellijVersion(t *testing.T) { -+// TestDoctorChecksTmuxVersionFailsOnError covers the case where tmux is found -+// but the version command fails. -+func TestDoctorChecksTmuxVersionFailsOnError(t *testing.T) { - setConfigEnv(t) -- c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { -+ c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { - if name == "/bin/git" { - return []byte("git version 2.43.0\n"), nil - } -- return []byte("zellij 0.44.2\n"), nil -+ return nil, errors.New("exec: tmux: not found") - }) - -- check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") -- if check.Level != doctorFail || !strings.Contains(check.Message, "require >= 0.44.3") { -- t.Fatalf("zellij check = %+v, want FAIL with minimum version", check) -+ check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") -+ if check.Level != doctorFail { -+ t.Fatalf("tmux check = %+v, want FAIL on version error", check) - } - } - --func TestDoctorWarnsWhenZellijMissing(t *testing.T) { -+func TestDoctorWarnsWhenTmuxMissing(t *testing.T) { - setConfigEnv(t) - c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { - return []byte("git version 2.43.0\n"), nil - }) - -- check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") -+ check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") - if check.Level != doctorWarn { -- t.Fatalf("zellij check = %+v, want WARN", check) -+ t.Fatalf("tmux check = %+v, want WARN", check) - } - } - - func TestDoctorChecksHarnessVersions(t *testing.T) { - setConfigEnv(t) - cmdPath := map[string]string{ - "git": "/bin/git", - "claude": "/bin/claude", - "codex": "/bin/codex", - } -diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go -index b5cc717..ae7bac4 100644 ---- a/backend/internal/cli/spawn.go -+++ b/backend/internal/cli/spawn.go -@@ -1,20 +1,22 @@ - package cli - - import ( - "context" - "fmt" - "net/url" -+ "runtime" - - "github.com/spf13/cobra" - "github.com/spf13/pflag" - -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - ) - - type spawnOptions struct { - project string - harness string - branch string - prompt string - issue string - claimPR string -@@ -90,28 +92,31 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { - } - } - out := cmd.OutOrStdout() - claimLabel := "" - if claimed != "" { - claimLabel = fmt.Sprintf(" (claimed %s)", claimed) - } - if _, err := fmt.Fprintf(out, "spawned session %s (%s)%s\n", res.Session.ID, res.Session.Status, claimLabel); err != nil { - return err - } -- // The daemon runs zellij under a short, non-default socket dir (see -- // zellij.DefaultSocketDir), so a plain `zellij attach` wouldn't find -- // the session — prefix the env so the hint is copy-pasteable. Use the -- // sanitised name zellij actually registers (zellij.SessionName): a long -- // session id maps to a different name than the raw id. -- attach := fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) -- if dir := zellij.DefaultSocketDir(); dir != "" { -- attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) -+ // Print a copy-pasteable attach hint for the selected runtime. -+ // On Darwin/Linux: tmux attach-session using the sanitised session name. -+ // On Windows: zellij attach with the short socket dir prefix. -+ var attach string -+ if runtime.GOOS != "windows" { -+ attach = fmt.Sprintf("tmux attach -t %s", tmux.SessionName(res.Session.ID)) -+ } else { -+ attach = fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) -+ if dir := zellij.DefaultSocketDir(); dir != "" { -+ attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) -+ } - } - _, err := fmt.Fprintf(out, "attach with: %s\n", attach) - return err - }, - } - f := cmd.Flags() - // --agent is an alias for --harness so the more intuitive `ao spawn --agent - // droid` works identically; both resolve to the same harness flag. - f.SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName { - if name == "agent" { -diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go -index 59791c8..29ca5ef 100644 ---- a/backend/internal/daemon/daemon.go -+++ b/backend/internal/daemon/daemon.go -@@ -6,21 +6,21 @@ package daemon - import ( - "context" - "fmt" - "log/slog" - "net/http" - "os" - "os/signal" - "syscall" - "time" - -- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/httpd" - "github.com/aoagents/agent-orchestrator/backend/internal/notify" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/preview" - "github.com/aoagents/agent-orchestrator/backend/internal/runfile" - notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" - projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" - "github.com/aoagents/agent-orchestrator/backend/internal/terminal" -@@ -75,55 +75,45 @@ func Run() error { - // signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the - // graceful shutdown inside Server.Run and stops the background goroutines. - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - cdcPipe, err := startCDC(ctx, store, log) - if err != nil { - return err - } - -- // Terminal streaming: the Zellij runtime supplies the PTY-attach command and -- // liveness; the CDC broadcaster feeds the session-state channel. The manager -+ // Terminal streaming: the selected runtime (tmux on macOS/Linux, zellij on Windows) supplies the -+ // PTY-attach command and liveness; the CDC broadcaster feeds the session-state channel. The manager - // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow -- // through the CDC change_log — only session-state events do. -- // zellij's default socket dir is too long on macOS for long session ids -- // (see zellij.DefaultSocketDir); use a short, stable one and ensure it exists. -- zellijSocketDir := zellij.DefaultSocketDir() -- if zellijSocketDir != "" { -- if err := os.MkdirAll(zellijSocketDir, 0o700); err != nil { -- // Don't abort startup, but surface it: every spawn's zellij session -- // would otherwise fail later with an opaque socket-bind error. -- log.Warn("could not create zellij socket dir; spawns may fail", "dir", zellijSocketDir, "error", err) -- } -- } -- runtimeAdapter := zellij.New(zellij.Options{SocketDir: zellijSocketDir}) -+ // through the CDC change_log -- only session-state events do. -+ runtimeAdapter := runtimeselect.New(log) - termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) - defer termMgr.Close() - - // The agent messenger sends validated user input to the session's live -- // zellij pane. Keep this path small until durable inbox semantics are needed. -+ // runtime pane. Keep this path small until durable inbox semantics are needed. - // Built before the Lifecycle Manager so the LCM can use it for SCM-driven - // agent nudges (CI failure, review feedback, merge conflict). - messenger := newSessionMessenger(store, runtimeAdapter, log) - notificationHub := notify.NewHub() - notifier := notificationsvc.New(notificationsvc.Deps{Store: store}) - notificationWriter := notify.New(notify.Deps{Store: store, Publisher: notificationHub}) - - // Bring up the Lifecycle Manager and the reaper first: it makes the session - // lifecycle write path live (reducer write -> store -> DB trigger -> - // change_log -> poller -> broadcaster) and gives startSession the shared LCM. - lcStack := startLifecycle(ctx, store, runtimeAdapter, messenger, notificationWriter, telemetrySink, log) - lcStack.scmDone = startSCMObserver(ctx, store, lcStack.LCM, log) - - // Wire the controller-facing session service over the same store + LCM, the -- // zellij runtime, a gitworktree workspace, the per-session agent resolver -+ // selected runtime, a gitworktree workspace, the per-session agent resolver - // (AO_AGENT validated here for compatibility), and the agent messenger, then mount it - // on the API. - sessionSvc, reviewSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, telemetrySink, log) - if err != nil { - stop() - lcStack.Stop() - if cdcErr := cdcPipe.Stop(); cdcErr != nil { - log.Error("cdc pipeline shutdown", "err", cdcErr) - } - return fmt.Errorf("wire session service: %w", err) -diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go -index 3bf5ff7..5d21160 100644 ---- a/backend/internal/daemon/lifecycle_wiring.go -+++ b/backend/internal/daemon/lifecycle_wiring.go -@@ -3,21 +3,21 @@ package daemon - import ( - "context" - "fmt" - "log/slog" - "path/filepath" - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitydispatch" - agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/reviewer" -- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" - "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" - reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" - sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" - sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" -@@ -52,24 +52,24 @@ func startLifecycle(ctx context.Context, store *sqlite.Store, runtime ports.Runt - // Stop waits for the reaper goroutine to exit. The caller must cancel the ctx - // passed to startLifecycle before calling Stop. - func (l *lifecycleStack) Stop() { - <-l.reaperDone - if l.scmDone != nil { - <-l.scmDone - } - } - - // startSession builds the controller-facing session service: a session manager --// over the real zellij runtime, a per-session gitworktree workspace, the shared -+// over the selected runtime, a per-session gitworktree workspace, the shared - // store + LCM, the per-session agent resolver, and the agent messenger. The - // returned service is mounted at httpd APIDeps.Sessions. --func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { -+func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { - defaultAgent := cfg.Agent - if defaultAgent == "" { - defaultAgent = config.DefaultAgent - } - agents, err := buildAgentResolver(defaultAgent, log) - if err != nil { - return nil, nil, err - } - ws, err := gitworktree.New(gitworktree.Options{ - // Per-session worktrees live under the data dir, so a single AO_DATA_DIR -diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go -index a3acd55..21bd248 100644 ---- a/backend/internal/daemon/wiring_test.go -+++ b/backend/internal/daemon/wiring_test.go -@@ -3,20 +3,21 @@ package daemon - import ( - "context" - "errors" - "io" - "log/slog" - "sync" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters" -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - telemetryadapter "github.com/aoagents/agent-orchestrator/backend/internal/adapters/telemetry" - "github.com/aoagents/agent-orchestrator/backend/internal/cdc" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" - ) -@@ -142,23 +143,23 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { - store, err := sqlite.Open(t.TempDir()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = store.Close() }) - - log := slog.New(slog.NewTextHandler(io.Discard, nil)) - lcm := lifecycle.New(store, nil) - cfg := config.Config{DataDir: t.TempDir()} - -- runtime := zellij.New(zellij.Options{}) -- messenger := newSessionMessenger(store, runtime, log) -- svc, reviewSvc, err := startSession(cfg, runtime, store, lcm, messenger, telemetryadapter.NoopSink{}, log) -+ rt := runtimeselect.New(nil) -+ messenger := newSessionMessenger(store, rt, log) -+ svc, reviewSvc, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) - if err != nil { - t.Fatalf("startSession: %v", err) - } - if svc == nil { - t.Fatal("startSession returned nil session service") - } - if reviewSvc == nil { - t.Fatal("startSession returned nil review service") - } - } - ---- Changes --- - -backend/internal/adapters/runtime/runtimeselect/runtimeselect.go - @@ -0,0 +1,46 @@ - +// Package runtimeselect picks the correct runtime backend by platform: - +// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). - +package runtimeselect - + - +import ( - + "context" - + "log/slog" - + "os" - + "runtime" - + - + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" - + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - +) - + - +// Runtime is the union interface that both tmux and zellij satisfy. - +// It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods - +// the daemon wires directly. - +type Runtime interface { - + ports.Runtime // Create, Destroy, IsAlive - + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error - + GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) - + AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) - +} - + - +// Compile-time assertions: both adapters must implement the union interface. - +var _ Runtime = (*tmux.Runtime)(nil) - +var _ Runtime = (*zellij.Runtime)(nil) - + - +// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. - +// log is used only for the Windows zellij socket-dir warning; nil is safe. - +func New(log *slog.Logger) Runtime { - + if runtime.GOOS != "windows" { - + return tmux.New(tmux.Options{}) - + } - + // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. - + dir := zellij.DefaultSocketDir() - + if dir != "" { - + if err := os.MkdirAll(dir, 0o700); err != nil { - + if log != nil { - + log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) - + } - + } - + } - + return zellij.New(zellij.Options{SocketDir: dir}) - +} - +46 -0 - -backend/internal/adapters/runtime/tmux/commands.go - @@ -0,0 +1,64 @@ - +package tmux - + - +import "fmt" - + - +// newSessionArgs builds args for `tmux new-session -d -s -x 220 -y 50 - +// -c -c `. The shell -c form runs the launch command - +// inside the configured shell so exported env vars and quoting work correctly. - +func newSessionArgs(id, cwd, shellPath, launchCmd string) []string { - + return []string{ - + "new-session", "-d", - + "-s", id, - + "-x", "220", - + "-y", "50", - + "-c", cwd, - + shellPath, "-c", launchCmd, - + } - +} - + - +// setStatusOffArgs hides the tmux status bar for the given session. - +// set-option uses pane-targeting syntax which does not accept the `=` prefix, - +// so we pass the session name directly. - +func setStatusOffArgs(id string) []string { - + return []string{"set-option", "-t", id, "status", "off"} - +} - + - +// killSessionArgs builds args for `tmux kill-session -t =`. The `=` prefix - +// requests exact-name matching so a session "foo" does not accidentally match - +// "foobar" (tmux otherwise does unique-prefix matching). - +func killSessionArgs(id string) []string { - + return []string{"kill-session", "-t", exactSessionTarget(id)} - +} - + - +// hasSessionArgs builds args for `tmux has-session -t =`. The `=` prefix - +// requests exact-name matching (see killSessionArgs). - +func hasSessionArgs(id string) []string { - + return []string{"has-session", "-t", exactSessionTarget(id)} - +} - + - +// exactSessionTarget wraps id in tmux's exact-match prefix `=` so session- - +// selection commands (-t) target only the session with that precise name. - +// Only kill-session and has-session support this prefix; pane-targeting - +// commands (send-keys, capture-pane, set-option) use a plain session name. - +func exactSessionTarget(id string) string { - + return "=" + id - +} - + - +// sendKeysLiteralArgs builds args for `tmux send-keys -t -l `. - +// The -l flag stops tmux interpreting words like "Enter" as key names so the - +// text is sent verbatim. - +func sendKeysLiteralArgs(id, chunk string) []string { - + return []string{"send-keys", "-t", id, "-l", chunk} - +} - + - +// sendEnterArgs builds args for `tmux send-keys -t Enter` to submit the - +// queued input. - +func sendEnterArgs(id string) []string { - + return []string{"send-keys", "-t", id, "Enter"} - +} - + - +// capturePaneArgs builds args for `tmux capture-pane -t -p -S -`. - +// -p prints to stdout; -S - starts n lines back in history. - +func capturePaneArgs(id string, lines int) []string { - + return []string{"capture-pane", "-t", id, "-p", "-S", fmt.Sprintf("-%d", lines)} - +} - +64 -0 - -backend/internal/adapters/runtime/tmux/tmux.go - @@ -0,0 +1,482 @@ - +// Package tmux implements ports.Runtime using tmux sessions on Darwin/Linux. - +package tmux - + - +import ( - + "context" - + "crypto/sha256" - + "encoding/hex" - + "errors" - + "fmt" - + "os" - + "os/exec" - + "regexp" - + "sort" - + "strings" - + "time" - + "unicode/utf8" - + - + "github.com/aoagents/agent-orchestrator/backend/internal/domain" - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - +) - + - +const ( - + defaultTimeout = 5 * time.Second - + defaultChunkBytes = 16 * 1024 - +) - + - +var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - + - +var getenv = os.Getenv - + - +// Options configures a tmux Runtime. Every field has a sensible default (see - +// New), so the zero value is usable. - +type Options struct { - + Binary string // default "tmux" (resolved via exec.LookPath) - + Shell string // default $SHELL else /bin/sh - + Timeout time.Duration // default 5s - + ChunkSize int // default 16*1024 - +} - + - +// Runtime runs agent sessions inside tmux sessions, driving them via the tmux - +// CLI. It implements ports.Runtime. - +type Runtime struct { - + binary string - + shell string - + timeout time.Duration - + chunkSize int - + runner runner - +} - + - +var _ ports.Runtime = (*Runtime)(nil) - + - +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 = append(append([]string(nil), os.Environ()...), env...) - + return cmd.CombinedOutput() - +} - + - +func (execRunner) Start(env []string, name string, args ...string) error { - + cmd := exec.Command(name, args...) - + cmd.Env = append(append([]string(nil), os.Environ()...), env...) - + return cmd.Start() - +} - + - +// New builds a tmux Runtime, filling unset Options with defaults: binary "tmux" - +// (resolved via exec.LookPath), shell from $SHELL (else /bin/sh), and the - +// default timeout and output chunk size. - +func New(opts Options) *Runtime { - + binary := opts.Binary - + if binary == "" { - + if path, err := exec.LookPath("tmux"); err == nil { - + binary = path - + } else { - + binary = "tmux" - + } - + } - + timeout := opts.Timeout - + if timeout == 0 { - + timeout = defaultTimeout - + } - + shellPath := opts.Shell - + if shellPath == "" { - + shellPath = getenv("SHELL") - + } - + if shellPath == "" { - + shellPath = "/bin/sh" - + } - + chunkSize := opts.ChunkSize - + if chunkSize <= 0 { - + chunkSize = defaultChunkBytes - + } - + return &Runtime{ - + binary: binary, - + shell: shellPath, - ... (382 lines truncated) - +482 -0 - -backend/internal/adapters/runtime/tmux/tmux_integration_test.go - @@ -0,0 +1,143 @@ - +package tmux - + - +import ( - + "context" - + "os/exec" - + "strings" - + "testing" - + "time" - + - + "github.com/aoagents/agent-orchestrator/backend/internal/domain" - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - +) - + - +func TestRuntimeIntegration(t *testing.T) { - + if _, err := exec.LookPath("tmux"); err != nil { - + t.Skip("tmux unavailable") - + } - + - + ctx := context.Background() - + id := strings.ReplaceAll(t.Name(), "/", "_") - + r := New(Options{Timeout: 5 * time.Second}) - + - + // Ensure clean slate: ignore errors (session may not exist). - + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) - + - + t.Cleanup(func() { - + // Always destroy so a test failure never leaks a tmux session. - + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) - + }) - + - + h, err := r.Create(ctx, ports.RuntimeConfig{ - + SessionID: domain.SessionID(id), - + WorkspacePath: t.TempDir(), - + // Run a trivial command then drop into an interactive shell (the keep-alive - + // exec is added by buildLaunchCommand, but we also verify here that output - + // appears). - + Argv: []string{"sh", "-c", "echo hello-from-tmux"}, - + Env: map[string]string{"AO_SESSION_ID": id}, - + }) - + if err != nil { - + t.Fatalf("Create: %v", err) - + } - + - + alive, err := r.IsAlive(ctx, h) - + if err != nil { - + t.Fatalf("IsAlive: %v", err) - + } - + if !alive { - + t.Fatal("alive = false, want true after create") - + } - + - + // Wait for the echo output to appear (the session may take a moment to - + // write it to the pane history). - + out := waitForOutput(t, r, h, "hello-from-tmux", 5*time.Second) - + if !strings.Contains(out, "hello-from-tmux") { - + t.Fatalf("output = %q, want hello-from-tmux", out) - + } - + - + // Send a command and verify it echoes back. - + if err := r.SendMessage(ctx, h, "echo hello-send"); err != nil { - + t.Fatalf("SendMessage: %v", err) - + } - + out = waitForOutput(t, r, h, "hello-send", 5*time.Second) - + if !strings.Contains(out, "hello-send") { - + t.Fatalf("output after SendMessage = %q, want hello-send", out) - + } - + - + // Destroy and verify liveness goes false. - + if err := r.Destroy(ctx, h); err != nil { - + t.Fatalf("Destroy: %v", err) - + } - + alive, err = r.IsAlive(ctx, h) - + if err != nil { - + t.Fatalf("IsAlive after destroy: %v", err) - + } - + if alive { - + t.Fatal("alive after destroy = true, want false") - + } - +} - + - +// TestRuntimeIntegrationExactSessionParsing verifies that IsAlive uses exact - +// session matching and does not treat a prefix as a live session. - +func TestRuntimeIntegrationExactSessionParsing(t *testing.T) { - + if _, err := exec.LookPath("tmux"); err != nil { - + t.Skip("tmux unavailable") - + } - + - + ctx := context.Background() - + base := strings.ReplaceAll(t.Name(), "/", "_") - + longID := base + "_long" - + prefixID := base - + - + r := New(Options{Timeout: 5 * time.Second}) - + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) - + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID}) - + - + t.Cleanup(func() { - + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: longID}) - + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: prefixID}) - + }) - ... (43 lines truncated) - +143 -0 - -backend/internal/adapters/runtime/tmux/tmux_test.go - @@ -0,0 +1,616 @@ - +package tmux - + - +import ( - + "context" - + "errors" - + "os/exec" - + "reflect" - + "strings" - + "testing" - + "time" - + - + "github.com/aoagents/agent-orchestrator/backend/internal/domain" - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - +) - + - +// -- fakeRunner test seam (mirrors zellij_test.go exactly) -- - + - +type fakeRunner struct { - + calls []runnerCall - + outputs [][]byte - + err error - +} - + - +type runnerCall struct { - + env []string - + name string - + args []string - +} - + - +func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { - + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) - + var out []byte - + if len(f.outputs) > 0 { - + out = f.outputs[0] - + f.outputs = f.outputs[1:] - + } - + if f.err != nil { - + return out, f.err - + } - + return out, nil - +} - + - +func (f *fakeRunner) Start(env []string, name string, args ...string) error { - + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) - + if len(f.outputs) > 0 { - + f.outputs = f.outputs[1:] - + } - + return f.err - +} - + - +// -- helpers -- - + - +func newTestRuntime(chunkSize int) (*Runtime, *fakeRunner) { - + fr := &fakeRunner{} - + r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/sh", ChunkSize: chunkSize}) - + r.runner = fr - + return r, fr - +} - + - +// -- Options / New tests -- - + - +func TestNewDefaultsToPortableShell(t *testing.T) { - + t.Setenv("SHELL", "") - + r := New(Options{}) - + if got := r.shell; got != "/bin/sh" { - + t.Fatalf("default shell = %q, want /bin/sh", got) - + } - +} - + - +func TestNewPicksUpShellFromEnv(t *testing.T) { - + t.Setenv("SHELL", "/bin/zsh") - + r := New(Options{}) - + if got := r.shell; got != "/bin/zsh" { - + t.Fatalf("shell = %q, want /bin/zsh", got) - + } - +} - + - +// -- command builder tests -- - + - +func TestCommandBuilders(t *testing.T) { - + if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/sh", `echo hi; exec "${SHELL:-/bin/sh}" -i`), - + []string{"new-session", "-d", "-s", "sess-1", "-x", "220", "-y", "50", "-c", "/tmp/ws", "/bin/sh", "-c", `echo hi; exec "${SHELL:-/bin/sh}" -i`}; !reflect.DeepEqual(got, want) { - + t.Fatalf("newSessionArgs = %#v, want %#v", got, want) - + } - + // set-option uses pane-targeting (no = prefix). - + if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "sess-1", "status", "off"}; !reflect.DeepEqual(got, want) { - + t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want) - + } - + // kill-session and has-session use exact-match prefix =. - + if got, want := killSessionArgs("sess-1"), []string{"kill-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { - + t.Fatalf("killSessionArgs = %#v, want %#v", got, want) - + } - + if got, want := hasSessionArgs("sess-1"), []string{"has-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { - + t.Fatalf("hasSessionArgs = %#v, want %#v", got, want) - + } - + if got, want := sendKeysLiteralArgs("sess-1", "hello"), []string{"send-keys", "-t", "sess-1", "-l", "hello"}; !reflect.DeepEqual(got, want) { - + t.Fatalf("sendKeysLiteralArgs = %#v, want %#v", got, want) - + } - + if got, want := sendEnterArgs("sess-1"), []string{"send-keys", "-t", "sess-1", "Enter"}; !reflect.DeepEqual(got, want) { - + t.Fatalf("sendEnterArgs = %#v, want %#v", got, want) - ... (516 lines truncated) - +616 -0 - -backend/internal/cli/doctor.go - @@ -4,20 +4,21 @@ import ( - + "runtime" - "strconv" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - ) - @@ -161,21 +162,21 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { - - c.checkZellij(ctx), - + c.checkTerminalRuntime(ctx), - c.checkAOBinary(), - ) - for _, harness := range doctorHarnesses { - checks = append(checks, c.checkHarness(ctx, harness)) - } - checks = append(checks, c.checkCodexLaunchFlags(ctx), c.checkGitHubToken(ctx)) - return checks - } - - // checkStore inspects the SQLite store WITHOUT opening or migrating it. The - @@ -283,20 +284,47 @@ func (c *commandContext) checkGit(ctx context.Context) doctorCheck { - +// checkTerminalRuntime checks for the runtime multiplexer used on this platform: - +// tmux on Darwin/Linux, zellij on Windows. - +func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { - + if runtime.GOOS == "windows" { - + return c.checkZellij(ctx) - + } - + return c.checkTmux(ctx) - +} - + - +func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { - + path, err := c.deps.LookPath("tmux") - + if err != nil || path == "" { - + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"} - + } - + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) - + defer cancel() - + out, err := c.deps.CommandOutput(reqCtx, path, "-V") - + if err != nil { - + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s: %v", path, err)} - + } - + version := firstOutputLine(out) - + if version == "" { - + version = "version unknown" - + } - + return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s)", path, version)} - +} - + - func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { - path, err := c.deps.LookPath("zellij") - if err != nil || path == "" { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "zellij", Message: "not found in PATH"} - } - reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) - defer cancel() - out, err := c.deps.CommandOutput(reqCtx, path, "--version") - if err != nil { - return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} - +29 -1 - -backend/internal/cli/doctor_test.go - @@ -45,67 +45,69 @@ func TestDoctorWarnsOnUnsupportedGitVersion(t *testing.T) { - -func TestDoctorChecksZellijVersion(t *testing.T) { - +func TestDoctorChecksTmuxVersion(t *testing.T) { - setConfigEnv(t) - - c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, args ...string) ([]byte, error) { - + c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, args ...string) ([]byte, error) { - -... (more changes truncated) - +2 -2 -[full diff: rtk git diff --no-compact] diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md deleted file mode 100644 index 5cf59b05..00000000 --- a/.superpowers/sdd/progress.md +++ /dev/null @@ -1,52 +0,0 @@ -# Migration: Zellij -> tmux (Darwin/Linux) + ConPTY (Windows) - -Branch: migrate-zellij-to-tmux-conpty -Base commit: e970f72 - -## Phase A (Darwin/Linux, fully testable here) -- Task 1: tmux runtime adapter (adapters/runtime/tmux/) — COMPLETE -- Task 2: runtime selection + daemon wiring + doctor/spawn hints — COMPLETE - -## Ledger -Task 1: complete (commits e970f72..44c3e61, review clean — spec PASS, 34 tests green, no em dashes) -Task 2: complete (commits 44c3e61..4b21e4b, review clean — spec PASS, quality APPROVED, 1585 tests green, GOOS=windows builds; cosmetic minors fixed in 4b21e4b) - -## Phase A FINAL: SHIP (whole-branch review verified vs real tmux 3.6b) -- darwin/linux/windows all build; full suite 1585 passed in 75 packages. -- IsAlive dead-vs-transient contract, keep-alive session survival, attach path, selector wiring, union interface all verified. -- Handle-ID format change (bare session id vs zellij session/pane) breaks no consumer (none parse it). -- Dead runner.Start removed (commit 926ee31). HEAD = 926ee31. -- Known follow-up (non-blocking): stale "Zellij" comments in internal/terminal/attachment.go are now runtime-neutral in behavior but Zellij-worded in prose. - -## Phase B (Windows ConPTY) — IN PROGRESS -User directive: port agent-orchestrator's proven detached pty-host + named-pipe + -registry design to Go (NOT the in-process holder). Integration = strategy 2: -evolve terminal PTYSource.AttachCommand -> Attacher.Attach(...) Stream so conpty -dials the pipe directly. Windows-only code can only be compile-checked here. - -Tasks: -- B1: conpty protocol codec + ring buffer (cross-platform, fully unit-tested) — COMPLETE (926ee31..d67941b, 16 tests, -race clean, review APPROVED) -- B2: windows pty-host registry — COMPLETE (..6552e26, 10 tests -race clean, win+linux+darwin build) -- B3: pty-host serve engine (loopback TCP, NOT named pipe; ConPTY behind interface seam) — COMPLETE (41ce417..6cd6e2a, 35 tests -race clean incl. ordering regression test, review APPROVED; loopback decision documented; Windows go-pty file compile-checked only) -- B4: conpty runtime adapter — COMPLETE (be06609..0bc26e5, 49 tests -race, IsAlive dead-vs-transient fixed, registry-recovery path; Windows spawn file compile-checked only) -- B5: terminal Attach/Stream interface change + tmux/zellij/conpty Attach — COMPLETE (8d757ed..HEAD, 1638 tests -race across 78 pkgs, all 3 GOOS, real tmux+zellij attach integration pass, review APPROVED; conpty loopbackStream stress-tested clean) -- B6: select conpty on Windows + delete zellij + pty-host subcommand + doctor/spawn — COMPLETE (b9ef1f5..HEAD, 1607 tests -race across 77 pkgs, all 3 GOOS, zellij GONE, arg-contract verified, review APPROVED) - -Faithful-port references (agent-orchestrator): -- packages/plugins/runtime-process/src/pty-host.ts (protocol, ring, fan-out, shutdown) -- packages/plugins/runtime-process/src/pty-client.ts (chunking 512/15ms, Enter 300ms, STATUS probe) -- packages/plugins/runtime-process/src/index.ts (detached spawn, READY handshake, destroy grace) -- packages/core/src/windows-pty-registry.ts (sideband JSON, PID-liveness prune) - -## Phase B (Windows, needs a Windows box to verify) — DEFERRED -- In-process ConPTY runtime + Attacher/Stream interface change + delete zellij - -## Ledger -(append "Task N: complete (commits .., review clean)" as tasks finish) - -## FINAL: Phase B SHIP (whole-branch review) -All 6 Phase B tasks complete + reviewed. End state: tmux (Darwin/Linux), conpty (Windows), zellij deleted. -darwin/linux/windows build; 1607 tests -race across 77 pkgs; vet clean. -VERIFIED on Darwin: full tmux path + all cross-platform conpty internals (protocol, ring, host serve engine over real loopback w/ fake ptyConn, client/attach, registry, dead-vs-transient IsAlive, injectable-spawn Create/Destroy). -UNVERIFIED (needs Windows hardware): real go-pty ConPTY child spawn, detached-spawn survival, OpenProcess pidAlive, 0x800700e8 cleanup ordering. -HEAD = 284e840 diff --git a/.superpowers/sdd/task-1-brief.md b/.superpowers/sdd/task-1-brief.md deleted file mode 100644 index 32f376b9..00000000 --- a/.superpowers/sdd/task-1-brief.md +++ /dev/null @@ -1,141 +0,0 @@ -# Task 1: tmux runtime adapter - -## Goal -Create a new Go package `internal/adapters/runtime/tmux` that drives agent -sessions through the **tmux** CLI, implementing the SAME method set the existing -zellij adapter exposes, so it is a drop-in replacement on Darwin/Linux. This task -adds the package and its tests ONLY. It does NOT wire it into the daemon and does -NOT delete zellij (that is Task 2). The build must stay green. - -Module path: `github.com/aoagents/agent-orchestrator/backend` -Work inside: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode/backend` - -## The template to mirror -The zellij adapter is your structural template. READ THESE FIRST: -- `internal/adapters/runtime/zellij/zellij.go` (Runtime struct, Options, New, the - `runner` interface + `execRunner`, Create/Destroy/IsAlive/SendMessage/GetOutput/ - AttachCommand, session-name sanitization, `chunks`, `tailLines`, - `trimTrailingBlankLines`, `validateEnvKeys`, `sortedKeys`, `commandError`). -- `internal/adapters/runtime/zellij/commands.go` (arg builders + shell quoting). -- `internal/adapters/runtime/zellij/zellij_test.go` (the `fakeRunner` test seam: - `type fakeRunner struct { calls []runnerCall; outputs [][]byte; err error }`, - injected via `r.runner = &fakeRunner{...}`). Mirror this exactly for tmux tests. - -Reuse the same idioms (Go style, comment density, error wrapping `fmt.Errorf("tmux -runtime: ...: %w", err)`). Match the surrounding code. - -## Interface the package must satisfy -The package's `Runtime` type must implement, with these EXACT signatures (they are -the existing consumer contracts in the repo — verify against `internal/ports/ -outbound.go`, `internal/terminal/attachment.go` `PTYSource`, `internal/review/ -launcher.go` `reviewerRuntime`, `internal/daemon/lifecycle_wiring.go` -`runtimeMessageSender`): - -```go -func New(opts Options) *Runtime -func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) -func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error -func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) -func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error -func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) -func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) -``` - -Add a compile-time assertion: `var _ ports.Runtime = (*Runtime)(nil)`. - -`ports.RuntimeConfig` fields: `SessionID domain.SessionID`, `WorkspacePath string`, -`Argv []string`, `Env map[string]string`. `ports.RuntimeHandle{ ID string }`. - -## tmux command mapping (the substance) -Use a `runner` interface identical in shape to zellij's (`Run(ctx, env, name, -args...) ([]byte, error)` and `Start(env, name, args...) error`) with an -`execRunner` default, so tests inject a `fakeRunner`. The tmux binary defaults to -`"tmux"` (allow `Options.Binary` override; resolve via `exec.LookPath`). - -- **Create**: validate session id (sanitize like zellij's `SessionName`; tmux - session names must not contain `.` or `:` — those are window/pane separators — - and must be non-empty; reuse the same sanitize-to-`[A-Za-z0-9_-]`+hash approach). - Validate WorkspacePath non-empty, Argv non-empty, env keys (port - `validateEnvKeys`). Then: - `tmux new-session -d -s -x 220 -y 50 -c ` - where `` runs the agent then **execs a keep-alive shell so the tmux - session survives the agent exiting** (this is the whole reason a multiplexer is - used). Build the launch as a single shell command string passed to - `sh -c` (or the configured shell), of the form: - `export K=V; ...; export PATH=...; ; exec "${SHELL:-/bin/sh}" -i` - Reuse zellij's `wrapLaunchCommandUnix` quoting approach (single-quote each env - value and each argv word). Pass env to the agent via the exported-vars prefix - (do NOT rely on tmux `-e`, which has its own quirks). After creating, set - `tmux set-option -t status off` (hide the status bar in the embedded web - terminal). Then verify liveness via IsAlive; on failure, Destroy and return an - error (mirror zellij's create cleanup discipline). Return - `ports.RuntimeHandle{ID: }`. NOTE: tmux needs no pane-id discovery — the - handle is just the session id. Keep the handle format simple (the session id); - do NOT invent a `session/pane` split unless a consumer requires it (none does — - verify). -- **Destroy**: `tmux kill-session -t `. Treat "session not found"/"can't find - session" stderr on a non-zero exit as success (idempotent), mirroring zellij's - `deleteSessionMissingOutput`. -- **IsAlive**: `tmux has-session -t `. Exit 0 => alive. A non-zero exit whose - output says the session/server is missing ("can't find session", "no server - running", "error connecting") => definitively `false, nil`. Any OTHER error => - `false, err` (a probe failure, NOT proof of death) — this distinction matters - because the reaper feeds the lifecycle manager and must not kill a session on a - transient probe error. Mirror zellij's IsAlive contract precisely. -- **SendMessage**: send literal text then Enter. Use - `tmux send-keys -t -l ` for the literal text (the `-l` flag stops - tmux interpreting words like "Enter"/"C-c" as key names), chunked via the ported - `chunks(message, chunkSize)` helper, then `tmux send-keys -t Enter` to - submit. (Per the agent-orchestrator reference, multiline text can alternatively - go via `load-buffer -`/`paste-buffer`, but `send-keys -l` chunked is simpler and - correct — use it. Mark any simplification with a `ponytail:` comment.) -- **GetOutput**: `tmux capture-pane -t -p -S -` (the `-S -` starts n - lines back in history; `-p` prints to stdout). Then apply the ported - `trimTrailingBlankLines` + `tailLines(out, lines)`. `lines <= 0` => error, like - zellij. -- **AttachCommand**: return argv `["tmux", "attach-session", "-t", ]` (use the - resolved binary path as argv[0]) and a `nil` env block (no per-session socket dir - needed for tmux — unlike zellij's Windows ConPTY env). The terminal layer's - existing Unix `defaultSpawn` (creack/pty) will spawn this unchanged. - -## Options -```go -type Options struct { - Binary string // default "tmux" (resolved via exec.LookPath) - Shell string // default $SHELL else /bin/sh - Timeout time.Duration // default 5s - ChunkSize int // default 16*1024 -} -``` - -## Tests (REQUIRED — this is the runnable check) -1. **Unit tests** `tmux_test.go` using a `fakeRunner` (copy the struct shape from - zellij_test.go: records calls, returns scripted outputs/err). Cover: command - args for Create (new-session + status off), Destroy, IsAlive (alive / missing => - false,nil / transient error => err), SendMessage (chunking + -l + Enter), - GetOutput (tail/trim), AttachCommand argv, session-name sanitization, env-key - validation, the keep-alive shell is present in the launch command, env vars are - exported in the launch command. -2. **Integration test** `tmux_integration_test.go` gated on - `if _, err := exec.LookPath("tmux"); err != nil { t.Skip(...) }` (mirror - `zellij_integration_test.go`). Real end-to-end: Create a session running a - trivial command (e.g. `sh -c 'echo hello; ...'` via Argv), assert IsAlive true, - SendMessage, GetOutput contains expected text, Destroy, then IsAlive false. - Use a unique session id (incorporate the test name) and ALWAYS Destroy in a - `t.Cleanup` so a failed test never leaks a tmux session on the dev machine. - tmux 3.6b IS installed here, so this test WILL run — make it pass. - -## Constraints / definition of done -- `cd backend && go build ./... ` succeeds. -- `cd backend && go test ./internal/adapters/runtime/tmux/...` passes (unit + - integration), and `go vet ./internal/adapters/runtime/tmux/...` is clean. -- Do NOT modify any file outside the new `internal/adapters/runtime/tmux/` package. -- Do NOT add new dependencies (tmux is shelled out via os/exec; no Go module - needed). go.mod must be unchanged. -- Follow the user's hard rule: **never use em dashes** in code, comments, or commit - messages. Use periods/commas/parentheses. -- Keep it lazy/minimal (ponytail): no speculative abstraction, no pane-id machinery - tmux doesn't need, no config the daemon won't set. Mark any deliberate shortcut - with a `ponytail:` comment naming the ceiling. -- Commit your work on the current branch (`migrate-zellij-to-tmux-conpty`) with a - clear message. git author is already configured. diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md deleted file mode 100644 index 2fb994c2..00000000 --- a/.superpowers/sdd/task-1-report.md +++ /dev/null @@ -1,129 +0,0 @@ -# Task 1 Report: tmux Runtime Adapter - -## What was built - -New package `backend/internal/adapters/runtime/tmux` implementing `ports.Runtime` -via the tmux CLI. Four files were created (no existing files modified): - -- `backend/internal/adapters/runtime/tmux/tmux.go` - Runtime struct, Options, New, - Create/Destroy/IsAlive/SendMessage/GetOutput/AttachCommand, session-name - sanitization, helpers (chunks, tailLines, trimTrailingBlankLines, - validateEnvKeys, sortedKeys, shellQuote, buildLaunchCommand, commandError). -- `backend/internal/adapters/runtime/tmux/commands.go` - Arg builders for all tmux - subcommands (newSessionArgs, setStatusOffArgs, killSessionArgs, hasSessionArgs, - sendKeysLiteralArgs, sendEnterArgs, capturePaneArgs, exactSessionTarget). -- `backend/internal/adapters/runtime/tmux/tmux_test.go` - 32 unit tests via fakeRunner. -- `backend/internal/adapters/runtime/tmux/tmux_integration_test.go` - 2 integration - tests gated on `exec.LookPath("tmux")`. - -## Design choices - -1. Handle format: plain session id string (no session/pane split). tmux needs no - pane-id discovery; the handle is just `ports.RuntimeHandle{ID: }`. - -2. Exact session targeting: `kill-session -t =` and `has-session -t =` use - tmux's `=` exact-name prefix (supported by session-selection commands in tmux - 3.x) to prevent prefix matching ("foo" matching "foobar"). Commands that use - pane-targeting syntax (set-option, send-keys, capture-pane) use a plain session - name because they do not support the `=` prefix. - -3. Keep-alive shell: `buildLaunchCommand` appends `; exec ${SHELL:-/bin/sh} -i` so - the tmux session survives agent exit (the whole reason a multiplexer is used). - -4. Send-keys chunking: `send-keys -t -l ` with `-l` flag sends text - literally (tmux does not interpret "Enter", "C-c", etc. as key names). Chunked - via ported `chunks()` helper with 16 KB default. - -5. `sessionMissingOutput` covers: "can't find session", "no server running", - "error connecting", "session not found". Both `killSessionMissingOutput` and the - IsAlive path use this to distinguish definitive-dead from probe-error. - -6. `AttachCommand` returns `["tmux", "attach-session", "-t", id]` with nil env - (no per-session socket dir needed unlike zellij's Windows path). - -7. `ponytail:` comments mark the two deliberate simplifications: - - send-keys -l chunked vs. load-buffer/paste-buffer (ceiling: very large - messages are slightly slower; 16 KB default is ample for agent prompts). - - PATH handling matches the zellij unix path. - -## Verification output - -``` -$ cd backend && go build ./... -# success (no output) - -$ go test ./internal/adapters/runtime/tmux/... -v -# 34 tests passed (32 unit + 2 integration with real tmux 3.6b) - -$ go vet ./internal/adapters/runtime/tmux/... -# no issues -``` - -## Concerns - -None. The build is green, all 34 tests pass (including the integration tests on -the installed tmux 3.6b), and no files outside the new package were modified. - ---- - -# Code-Review Fix Report (commit 44c3e61) - -## What was fixed - -Four findings from code review, all scoped to `backend/internal/adapters/runtime/tmux/`. - -### Finding 1: Em dash removed (hard project rule) - -File: `tmux_test.go:462` - -Replaced the em dash character in the fatal message with a semicolon: -- Before: `"IsAlive: got nil, want probe error -- failed probe must not read as dead"` (with actual em-dash Unicode) -- After: `"IsAlive: got nil, want probe error; failed probe must not read as dead"` - -A full package grep confirmed zero remaining em dashes. - -### Finding 2: Integration test session IDs derived from t.Name() - -File: `tmux_integration_test.go` - -Both integration tests previously used hardcoded session IDs that would collide under `-count=2` or parallel runs: -- `TestRuntimeIntegration`: was `"ao_itest_tmux"`, now `strings.ReplaceAll(t.Name(), "/", "_")` -- `TestRuntimeIntegrationExactSessionParsing`: was `"ao_tmux_exact_long"` / `"ao_tmux_exact"`, now `base + "_long"` / `base` where `base = strings.ReplaceAll(t.Name(), "/", "_")` - -Added `domain` import to support the `domain.SessionID(id)` conversion in `RuntimeConfig.SessionID`. - -Existing `t.Cleanup`/Destroy guards retained as-is. - -### Finding 3: Dead scaffolding removed - -File: `tmux_test.go`, `TestCreateDestroysAndReturnsErrorWhenNotAlive` - -Removed 14 lines of dead scaffolding: unused `r, fr` and `r2, fr2` variables, their dead output assignments, and the stale comments explaining why they were bypassed. Only the live `r2 + fr3` path remains. Test assertions are unchanged. - -### Finding 4: Quoted `${SHELL:-/bin/sh}` in tmux.go - -File: `tmux.go` (line 464), `tmux_test.go` (2 assertion sites) - -Changed the keep-alive snippet from `exec ${SHELL:-/bin/sh} -i` to `exec "${SHELL:-/bin/sh}" -i` (double-quoted) to handle a `$SHELL` value with spaces in the path. - -Updated two unit test assertion strings to match: -- `TestCommandBuilders`: both the input and want literals updated to the quoted form -- `TestCreateLaunchCommandContainsKeepAliveShell`: `strings.Contains` check updated to the quoted form - -## Verification command outputs - -``` -$ cd backend && go build ./... -# success - -$ go test ./internal/adapters/runtime/tmux/... -# 34 passed in 1 packages - -$ go vet ./internal/adapters/runtime/tmux/... -# no issues - -$ grep -rn "—" internal/adapters/runtime/tmux/ || echo "NO EM DASHES" -NO EM DASHES -``` - -Commit: `44c3e61` on branch `migrate-zellij-to-tmux-conpty`. diff --git a/.superpowers/sdd/task-1-review-package.txt b/.superpowers/sdd/task-1-review-package.txt deleted file mode 100644 index 1ad6f659..00000000 --- a/.superpowers/sdd/task-1-review-package.txt +++ /dev/null @@ -1,2092 +0,0 @@ -=== COMMITS === -bc23964 feat(runtime): add tmux adapter package - -=== STAT === -.superpowers/sdd/task-1-brief.md | 141 +++++ - .superpowers/sdd/task-1-report.md | 65 +++ - backend/internal/adapters/runtime/tmux/commands.go | 64 +++ - backend/internal/adapters/runtime/tmux/tmux.go | 482 ++++++++++++++++ - .../adapters/runtime/tmux/tmux_integration_test.go | 141 +++++ - .../internal/adapters/runtime/tmux/tmux_test.go | 630 +++++++++++++++++++++ - 6 files changed, 1523 insertions(+) - -=== DIFF === -.superpowers/sdd/task-1-brief.md | 141 +++++ - .superpowers/sdd/task-1-report.md | 65 +++ - backend/internal/adapters/runtime/tmux/commands.go | 64 +++ - backend/internal/adapters/runtime/tmux/tmux.go | 482 ++++++++++++++++ - .../adapters/runtime/tmux/tmux_integration_test.go | 141 +++++ - .../internal/adapters/runtime/tmux/tmux_test.go | 630 +++++++++++++++++++++ - 6 files changed, 1523 insertions(+) - -diff --git a/.superpowers/sdd/task-1-brief.md b/.superpowers/sdd/task-1-brief.md -new file mode 100644 -index 0000000..32f376b ---- /dev/null -+++ b/.superpowers/sdd/task-1-brief.md -@@ -0,0 +1,141 @@ -+# Task 1: tmux runtime adapter -+ -+## Goal -+Create a new Go package `internal/adapters/runtime/tmux` that drives agent -+sessions through the **tmux** CLI, implementing the SAME method set the existing -+zellij adapter exposes, so it is a drop-in replacement on Darwin/Linux. This task -+adds the package and its tests ONLY. It does NOT wire it into the daemon and does -+NOT delete zellij (that is Task 2). The build must stay green. -+ -+Module path: `github.com/aoagents/agent-orchestrator/backend` -+Work inside: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode/backend` -+ -+## The template to mirror -+The zellij adapter is your structural template. READ THESE FIRST: -+- `internal/adapters/runtime/zellij/zellij.go` (Runtime struct, Options, New, the -+ `runner` interface + `execRunner`, Create/Destroy/IsAlive/SendMessage/GetOutput/ -+ AttachCommand, session-name sanitization, `chunks`, `tailLines`, -+ `trimTrailingBlankLines`, `validateEnvKeys`, `sortedKeys`, `commandError`). -+- `internal/adapters/runtime/zellij/commands.go` (arg builders + shell quoting). -+- `internal/adapters/runtime/zellij/zellij_test.go` (the `fakeRunner` test seam: -+ `type fakeRunner struct { calls []runnerCall; outputs [][]byte; err error }`, -+ injected via `r.runner = &fakeRunner{...}`). Mirror this exactly for tmux tests. -+ -+Reuse the same idioms (Go style, comment density, error wrapping `fmt.Errorf("tmux -+runtime: ...: %w", err)`). Match the surrounding code. -+ -+## Interface the package must satisfy -+The package's `Runtime` type must implement, with these EXACT signatures (they are -+the existing consumer contracts in the repo — verify against `internal/ports/ -+outbound.go`, `internal/terminal/attachment.go` `PTYSource`, `internal/review/ -+launcher.go` `reviewerRuntime`, `internal/daemon/lifecycle_wiring.go` -+`runtimeMessageSender`): -+ -+```go -+func New(opts Options) *Runtime -+func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) -+func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error -+func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) -+func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error -+func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) -+func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) -+``` -+ -+Add a compile-time assertion: `var _ ports.Runtime = (*Runtime)(nil)`. -+ -+`ports.RuntimeConfig` fields: `SessionID domain.SessionID`, `WorkspacePath string`, -+`Argv []string`, `Env map[string]string`. `ports.RuntimeHandle{ ID string }`. -+ -+## tmux command mapping (the substance) -+Use a `runner` interface identical in shape to zellij's (`Run(ctx, env, name, -+args...) ([]byte, error)` and `Start(env, name, args...) error`) with an -+`execRunner` default, so tests inject a `fakeRunner`. The tmux binary defaults to -+`"tmux"` (allow `Options.Binary` override; resolve via `exec.LookPath`). -+ -+- **Create**: validate session id (sanitize like zellij's `SessionName`; tmux -+ session names must not contain `.` or `:` — those are window/pane separators — -+ and must be non-empty; reuse the same sanitize-to-`[A-Za-z0-9_-]`+hash approach). -+ Validate WorkspacePath non-empty, Argv non-empty, env keys (port -+ `validateEnvKeys`). Then: -+ `tmux new-session -d -s -x 220 -y 50 -c ` -+ where `` runs the agent then **execs a keep-alive shell so the tmux -+ session survives the agent exiting** (this is the whole reason a multiplexer is -+ used). Build the launch as a single shell command string passed to -+ `sh -c` (or the configured shell), of the form: -+ `export K=V; ...; export PATH=...; ; exec "${SHELL:-/bin/sh}" -i` -+ Reuse zellij's `wrapLaunchCommandUnix` quoting approach (single-quote each env -+ value and each argv word). Pass env to the agent via the exported-vars prefix -+ (do NOT rely on tmux `-e`, which has its own quirks). After creating, set -+ `tmux set-option -t status off` (hide the status bar in the embedded web -+ terminal). Then verify liveness via IsAlive; on failure, Destroy and return an -+ error (mirror zellij's create cleanup discipline). Return -+ `ports.RuntimeHandle{ID: }`. NOTE: tmux needs no pane-id discovery — the -+ handle is just the session id. Keep the handle format simple (the session id); -+ do NOT invent a `session/pane` split unless a consumer requires it (none does — -+ verify). -+- **Destroy**: `tmux kill-session -t `. Treat "session not found"/"can't find -+ session" stderr on a non-zero exit as success (idempotent), mirroring zellij's -+ `deleteSessionMissingOutput`. -+- **IsAlive**: `tmux has-session -t `. Exit 0 => alive. A non-zero exit whose -+ output says the session/server is missing ("can't find session", "no server -+ running", "error connecting") => definitively `false, nil`. Any OTHER error => -+ `false, err` (a probe failure, NOT proof of death) — this distinction matters -+ because the reaper feeds the lifecycle manager and must not kill a session on a -+ transient probe error. Mirror zellij's IsAlive contract precisely. -+- **SendMessage**: send literal text then Enter. Use -+ `tmux send-keys -t -l ` for the literal text (the `-l` flag stops -+ tmux interpreting words like "Enter"/"C-c" as key names), chunked via the ported -+ `chunks(message, chunkSize)` helper, then `tmux send-keys -t Enter` to -+ submit. (Per the agent-orchestrator reference, multiline text can alternatively -+ go via `load-buffer -`/`paste-buffer`, but `send-keys -l` chunked is simpler and -+ correct — use it. Mark any simplification with a `ponytail:` comment.) -+- **GetOutput**: `tmux capture-pane -t -p -S -` (the `-S -` starts n -+ lines back in history; `-p` prints to stdout). Then apply the ported -+ `trimTrailingBlankLines` + `tailLines(out, lines)`. `lines <= 0` => error, like -+ zellij. -+- **AttachCommand**: return argv `["tmux", "attach-session", "-t", ]` (use the -+ resolved binary path as argv[0]) and a `nil` env block (no per-session socket dir -+ needed for tmux — unlike zellij's Windows ConPTY env). The terminal layer's -+ existing Unix `defaultSpawn` (creack/pty) will spawn this unchanged. -+ -+## Options -+```go -+type Options struct { -+ Binary string // default "tmux" (resolved via exec.LookPath) -+ Shell string // default $SHELL else /bin/sh -+ Timeout time.Duration // default 5s -+ ChunkSize int // default 16*1024 -+} -+``` -+ -+## Tests (REQUIRED — this is the runnable check) -+1. **Unit tests** `tmux_test.go` using a `fakeRunner` (copy the struct shape from -+ zellij_test.go: records calls, returns scripted outputs/err). Cover: command -+ args for Create (new-session + status off), Destroy, IsAlive (alive / missing => -+ false,nil / transient error => err), SendMessage (chunking + -l + Enter), -+ GetOutput (tail/trim), AttachCommand argv, session-name sanitization, env-key -+ validation, the keep-alive shell is present in the launch command, env vars are -+ exported in the launch command. -+2. **Integration test** `tmux_integration_test.go` gated on -+ `if _, err := exec.LookPath("tmux"); err != nil { t.Skip(...) }` (mirror -+ `zellij_integration_test.go`). Real end-to-end: Create a session running a -+ trivial command (e.g. `sh -c 'echo hello; ...'` via Argv), assert IsAlive true, -+ SendMessage, GetOutput contains expected text, Destroy, then IsAlive false. -+ Use a unique session id (incorporate the test name) and ALWAYS Destroy in a -+ `t.Cleanup` so a failed test never leaks a tmux session on the dev machine. -+ tmux 3.6b IS installed here, so this test WILL run — make it pass. -+ -+## Constraints / definition of done -+- `cd backend && go build ./... ` succeeds. -+- `cd backend && go test ./internal/adapters/runtime/tmux/...` passes (unit + -+ integration), and `go vet ./internal/adapters/runtime/tmux/...` is clean. -+- Do NOT modify any file outside the new `internal/adapters/runtime/tmux/` package. -+- Do NOT add new dependencies (tmux is shelled out via os/exec; no Go module -+ needed). go.mod must be unchanged. -+- Follow the user's hard rule: **never use em dashes** in code, comments, or commit -+ messages. Use periods/commas/parentheses. -+- Keep it lazy/minimal (ponytail): no speculative abstraction, no pane-id machinery -+ tmux doesn't need, no config the daemon won't set. Mark any deliberate shortcut -+ with a `ponytail:` comment naming the ceiling. -+- Commit your work on the current branch (`migrate-zellij-to-tmux-conpty`) with a -+ clear message. git author is already configured. -diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md -new file mode 100644 -index 0000000..08dc462 ---- /dev/null -+++ b/.superpowers/sdd/task-1-report.md -@@ -0,0 +1,65 @@ -+# Task 1 Report: tmux Runtime Adapter -+ -+## What was built -+ -+New package `backend/internal/adapters/runtime/tmux` implementing `ports.Runtime` -+via the tmux CLI. Four files were created (no existing files modified): -+ -+- `backend/internal/adapters/runtime/tmux/tmux.go` - Runtime struct, Options, New, -+ Create/Destroy/IsAlive/SendMessage/GetOutput/AttachCommand, session-name -+ sanitization, helpers (chunks, tailLines, trimTrailingBlankLines, -+ validateEnvKeys, sortedKeys, shellQuote, buildLaunchCommand, commandError). -+- `backend/internal/adapters/runtime/tmux/commands.go` - Arg builders for all tmux -+ subcommands (newSessionArgs, setStatusOffArgs, killSessionArgs, hasSessionArgs, -+ sendKeysLiteralArgs, sendEnterArgs, capturePaneArgs, exactSessionTarget). -+- `backend/internal/adapters/runtime/tmux/tmux_test.go` - 32 unit tests via fakeRunner. -+- `backend/internal/adapters/runtime/tmux/tmux_integration_test.go` - 2 integration -+ tests gated on `exec.LookPath("tmux")`. -+ -+## Design choices -+ -+1. Handle format: plain session id string (no session/pane split). tmux needs no -+ pane-id discovery; the handle is just `ports.RuntimeHandle{ID: }`. -+ -+2. Exact session targeting: `kill-session -t =` and `has-session -t =` use -+ tmux's `=` exact-name prefix (supported by session-selection commands in tmux -+ 3.x) to prevent prefix matching ("foo" matching "foobar"). Commands that use -+ pane-targeting syntax (set-option, send-keys, capture-pane) use a plain session -+ name because they do not support the `=` prefix. -+ -+3. Keep-alive shell: `buildLaunchCommand` appends `; exec ${SHELL:-/bin/sh} -i` so -+ the tmux session survives agent exit (the whole reason a multiplexer is used). -+ -+4. Send-keys chunking: `send-keys -t -l ` with `-l` flag sends text -+ literally (tmux does not interpret "Enter", "C-c", etc. as key names). Chunked -+ via ported `chunks()` helper with 16 KB default. -+ -+5. `sessionMissingOutput` covers: "can't find session", "no server running", -+ "error connecting", "session not found". Both `killSessionMissingOutput` and the -+ IsAlive path use this to distinguish definitive-dead from probe-error. -+ -+6. `AttachCommand` returns `["tmux", "attach-session", "-t", id]` with nil env -+ (no per-session socket dir needed unlike zellij's Windows path). -+ -+7. `ponytail:` comments mark the two deliberate simplifications: -+ - send-keys -l chunked vs. load-buffer/paste-buffer (ceiling: very large -+ messages are slightly slower; 16 KB default is ample for agent prompts). -+ - PATH handling matches the zellij unix path. -+ -+## Verification output -+ -+``` -+$ cd backend && go build ./... -+# success (no output) -+ -+$ go test ./internal/adapters/runtime/tmux/... -v -+# 34 tests passed (32 unit + 2 integration with real tmux 3.6b) -+ -+$ go vet ./internal/adapters/runtime/tmux/... -+# no issues -+``` -+ -+## Concerns -+ -+None. The build is green, all 34 tests pass (including the integration tests on -+the installed tmux 3.6b), and no files outside the new package were modified. -diff --git a/backend/internal/adapters/runtime/tmux/commands.go b/backend/internal/adapters/runtime/tmux/commands.go -new file mode 100644 -index 0000000..1c2cf30 ---- /dev/null -+++ b/backend/internal/adapters/runtime/tmux/commands.go -@@ -0,0 +1,64 @@ -+package tmux -+ -+import "fmt" -+ -+// newSessionArgs builds args for `tmux new-session -d -s -x 220 -y 50 -+// -c -c `. The shell -c form runs the launch command -+// inside the configured shell so exported env vars and quoting work correctly. -+func newSessionArgs(id, cwd, shellPath, launchCmd string) []string { -+ return []string{ -+ "new-session", "-d", -+ "-s", id, -+ "-x", "220", -+ "-y", "50", -+ "-c", cwd, -+ shellPath, "-c", launchCmd, -+ } -+} -+ -+// setStatusOffArgs hides the tmux status bar for the given session. -+// set-option uses pane-targeting syntax which does not accept the `=` prefix, -+// so we pass the session name directly. -+func setStatusOffArgs(id string) []string { -+ return []string{"set-option", "-t", id, "status", "off"} -+} -+ -+// killSessionArgs builds args for `tmux kill-session -t =`. The `=` prefix -+// requests exact-name matching so a session "foo" does not accidentally match -+// "foobar" (tmux otherwise does unique-prefix matching). -+func killSessionArgs(id string) []string { -+ return []string{"kill-session", "-t", exactSessionTarget(id)} -+} -+ -+// hasSessionArgs builds args for `tmux has-session -t =`. The `=` prefix -+// requests exact-name matching (see killSessionArgs). -+func hasSessionArgs(id string) []string { -+ return []string{"has-session", "-t", exactSessionTarget(id)} -+} -+ -+// exactSessionTarget wraps id in tmux's exact-match prefix `=` so session- -+// selection commands (-t) target only the session with that precise name. -+// Only kill-session and has-session support this prefix; pane-targeting -+// commands (send-keys, capture-pane, set-option) use a plain session name. -+func exactSessionTarget(id string) string { -+ return "=" + id -+} -+ -+// sendKeysLiteralArgs builds args for `tmux send-keys -t -l `. -+// The -l flag stops tmux interpreting words like "Enter" as key names so the -+// text is sent verbatim. -+func sendKeysLiteralArgs(id, chunk string) []string { -+ return []string{"send-keys", "-t", id, "-l", chunk} -+} -+ -+// sendEnterArgs builds args for `tmux send-keys -t Enter` to submit the -+// queued input. -+func sendEnterArgs(id string) []string { -+ return []string{"send-keys", "-t", id, "Enter"} -+} -+ -+// capturePaneArgs builds args for `tmux capture-pane -t -p -S -`. -+// -p prints to stdout; -S - starts n lines back in history. -+func capturePaneArgs(id string, lines int) []string { -+ return []string{"capture-pane", "-t", id, "-p", "-S", fmt.Sprintf("-%d", lines)} -+} -diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go -new file mode 100644 -index 0000000..3b47745 ---- /dev/null -+++ b/backend/internal/adapters/runtime/tmux/tmux.go -@@ -0,0 +1,482 @@ -+// Package tmux implements ports.Runtime using tmux sessions on Darwin/Linux. -+package tmux -+ -+import ( -+ "context" -+ "crypto/sha256" -+ "encoding/hex" -+ "errors" -+ "fmt" -+ "os" -+ "os/exec" -+ "regexp" -+ "sort" -+ "strings" -+ "time" -+ "unicode/utf8" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/domain" -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" -+) -+ -+const ( -+ defaultTimeout = 5 * time.Second -+ defaultChunkBytes = 16 * 1024 -+) -+ -+var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) -+ -+var getenv = os.Getenv -+ -+// Options configures a tmux Runtime. Every field has a sensible default (see -+// New), so the zero value is usable. -+type Options struct { -+ Binary string // default "tmux" (resolved via exec.LookPath) -+ Shell string // default $SHELL else /bin/sh -+ Timeout time.Duration // default 5s -+ ChunkSize int // default 16*1024 -+} -+ -+// Runtime runs agent sessions inside tmux sessions, driving them via the tmux -+// CLI. It implements ports.Runtime. -+type Runtime struct { -+ binary string -+ shell string -+ timeout time.Duration -+ chunkSize int -+ runner runner -+} -+ -+var _ ports.Runtime = (*Runtime)(nil) -+ -+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 = append(append([]string(nil), os.Environ()...), env...) -+ return cmd.CombinedOutput() -+} -+ -+func (execRunner) Start(env []string, name string, args ...string) error { -+ cmd := exec.Command(name, args...) -+ cmd.Env = append(append([]string(nil), os.Environ()...), env...) -+ return cmd.Start() -+} -+ -+// New builds a tmux Runtime, filling unset Options with defaults: binary "tmux" -+// (resolved via exec.LookPath), shell from $SHELL (else /bin/sh), and the -+// default timeout and output chunk size. -+func New(opts Options) *Runtime { -+ binary := opts.Binary -+ if binary == "" { -+ if path, err := exec.LookPath("tmux"); err == nil { -+ binary = path -+ } else { -+ binary = "tmux" -+ } -+ } -+ timeout := opts.Timeout -+ if timeout == 0 { -+ timeout = defaultTimeout -+ } -+ shellPath := opts.Shell -+ if shellPath == "" { -+ shellPath = getenv("SHELL") -+ } -+ if shellPath == "" { -+ shellPath = "/bin/sh" -+ } -+ chunkSize := opts.ChunkSize -+ if chunkSize <= 0 { -+ chunkSize = defaultChunkBytes -+ } -+ return &Runtime{ -+ binary: binary, -+ shell: shellPath, -+ timeout: timeout, -+ chunkSize: chunkSize, -+ runner: execRunner{}, -+ } -+} -+ -+// Create starts a new tmux session in the workspace, running the agent's -+// launch command with a keep-alive shell, and returns a handle to it. -+func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { -+ id, err := tmuxSessionName(cfg.SessionID) -+ if err != nil { -+ return ports.RuntimeHandle{}, err -+ } -+ if cfg.WorkspacePath == "" { -+ return ports.RuntimeHandle{}, errors.New("tmux runtime: workspace path is required") -+ } -+ if len(cfg.Argv) == 0 { -+ return ports.RuntimeHandle{}, errors.New("tmux runtime: launch command is required") -+ } -+ if err := validateEnvKeys(cfg.Env); err != nil { -+ return ports.RuntimeHandle{}, err -+ } -+ -+ launchCmd := buildLaunchCommand(cfg, r.shell) -+ args := newSessionArgs(id, cfg.WorkspacePath, r.shell, launchCmd) -+ if _, err := r.run(ctx, args...); err != nil { -+ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: create session %s: %w", id, err) -+ } -+ -+ // Hide the status bar in the embedded terminal: it clutters the view and -+ // was not designed for the in-browser display context. -+ if _, err := r.run(ctx, setStatusOffArgs(id)...); err != nil { -+ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) -+ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: set status %s: %w", id, err) -+ } -+ -+ handle := ports.RuntimeHandle{ID: id} -+ alive, err := r.IsAlive(ctx, handle) -+ if err != nil { -+ _ = r.Destroy(context.Background(), handle) -+ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: verify session %s: %w", id, err) -+ } -+ if !alive { -+ _ = r.Destroy(context.Background(), handle) -+ return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: session %s exited before ready", id) -+ } -+ return handle, nil -+} -+ -+// Destroy kills the handle's tmux session. An already-gone session is treated -+// as success (idempotent). -+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, killSessionArgs(id)...) -+ if err != nil { -+ var exitErr *exec.ExitError -+ if errors.As(err, &exitErr) && killSessionMissingOutput(string(out)) { -+ return nil -+ } -+ return fmt.Errorf("tmux runtime: destroy session %s: %w", id, err) -+ } -+ return nil -+} -+ -+// IsAlive reports whether the handle's session still exists via `tmux -+// has-session`. Exit 0 means alive. A non-zero exit with output indicating the -+// session or server is missing is a definitive false, nil. Any other non-zero -+// exit is a probe error (not proof of death) so callers (the reaper feeding -+// the LCM) treat it as a failed probe and never kill a session on a transient -+// error. -+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, hasSessionArgs(id)...) -+ if err != nil { -+ var exitErr *exec.ExitError -+ if errors.As(err, &exitErr) && sessionMissingOutput(string(out)) { -+ return false, nil -+ } -+ return false, fmt.Errorf("tmux runtime: probe session %s: %w", id, err) -+ } -+ return true, nil -+} -+ -+// SendMessage sends literal text to the session (chunked via send-keys -l) then -+// presses Enter to submit. -+// -+// ponytail: send-keys -l chunked is simpler than load-buffer/paste-buffer; the -+// ceiling is very large messages may be slower, but chunk size defaults to 16 KB -+// which is ample for agent prompts. -+func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { -+ id, err := handleID(handle) -+ if err != nil { -+ return err -+ } -+ for _, chunk := range chunks(message, r.chunkSize) { -+ if _, err := r.run(ctx, sendKeysLiteralArgs(id, chunk)...); err != nil { -+ return fmt.Errorf("tmux runtime: send message %s: %w", id, err) -+ } -+ } -+ if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil { -+ return fmt.Errorf("tmux runtime: send enter %s: %w", id, err) -+ } -+ return nil -+} -+ -+// GetOutput returns the last `lines` lines of the session pane's captured -+// output. -+func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { -+ id, err := handleID(handle) -+ if err != nil { -+ return "", err -+ } -+ if lines <= 0 { -+ return "", errors.New("tmux runtime: lines must be positive") -+ } -+ out, err := r.run(ctx, capturePaneArgs(id, lines)...) -+ if err != nil { -+ return "", fmt.Errorf("tmux runtime: capture output %s: %w", id, err) -+ } -+ return tailLines(trimTrailingBlankLines(string(out)), lines), nil -+} -+ -+// AttachCommand returns the argv a human runs to attach their terminal to the -+// session. No per-session env block is needed for tmux (unlike zellij's Windows -+// ConPTY path), so env is always nil. -+func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { -+ id, err := handleID(handle) -+ if err != nil { -+ return nil, nil, err -+ } -+ return []string{r.binary, "attach-session", "-t", id}, nil, nil -+} -+ -+// run wraps runner.Run with a per-call timeout context. -+func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { -+ cmdCtx, cancel := context.WithTimeout(ctx, r.timeout) -+ defer cancel() -+ out, err := r.runner.Run(cmdCtx, nil, r.binary, args...) -+ if cmdCtx.Err() != nil { -+ return out, cmdCtx.Err() -+ } -+ if err != nil { -+ return out, commandError{err: err, output: strings.TrimSpace(string(out))} -+ } -+ return out, nil -+} -+ -+// -- session name helpers -- -+ -+func tmuxSessionName(id domain.SessionID) (string, error) { -+ raw := string(id) -+ if raw == "" { -+ return "", errors.New("tmux runtime: session id is required") -+ } -+ return SessionName(raw), nil -+} -+ -+// SessionName returns the tmux session name the runtime registers for a given -+// session id, applying the same sanitisation Create does. Callers that print an -+// attach hint must use this rather than the raw id. -+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 handleID(handle ports.RuntimeHandle) (string, error) { -+ id := handle.ID -+ if id == "" { -+ return "", errors.New("tmux runtime: session id is required") -+ } -+ if !sessionIDPattern.MatchString(id) { -+ return "", fmt.Errorf("tmux runtime: invalid handle id %q", id) -+ } -+ return id, nil -+} -+ -+// -- output detection helpers -- -+ -+// sessionMissingOutput reports whether a non-zero `tmux has-session` or -+// `tmux kill-session` exit is definitively "session does not exist" rather -+// than a transient probe failure. -+func sessionMissingOutput(out string) bool { -+ s := strings.ToLower(out) -+ return strings.Contains(s, "can't find session") || -+ strings.Contains(s, "no server running") || -+ strings.Contains(s, "error connecting") || -+ strings.Contains(s, "session not found") -+} -+ -+// killSessionMissingOutput reports whether a non-zero `tmux kill-session` -+// failed because the session was already gone. -+func killSessionMissingOutput(out string) bool { -+ return sessionMissingOutput(out) -+} -+ -+// -- text helpers (ported from zellij) -- -+ -+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, "") -+} -+ -+// -- env / quoting helpers -- -+ -+func validateEnvKeys(env map[string]string) error { -+ for key := range env { -+ if !validEnvKey(key) { -+ return fmt.Errorf("tmux runtime: invalid env key %q", key) -+ } -+ } -+ return nil -+} -+ -+func validEnvKey(key string) bool { -+ if key == "" { -+ return false -+ } -+ for i, r := range key { -+ if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { -+ continue -+ } -+ if i > 0 && r >= '0' && r <= '9' { -+ continue -+ } -+ return false -+ } -+ return true -+} -+ -+func sortedKeys(m map[string]string) []string { -+ keys := make([]string, 0, len(m)) -+ for k := range m { -+ keys = append(keys, k) -+ } -+ sort.Strings(keys) -+ return keys -+} -+ -+func shellQuote(s string) string { -+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" -+} -+ -+// buildLaunchCommand builds the shell command string passed to `sh -c` (or the -+// configured shell). It exports env vars, then runs argv, then execs a -+// keep-alive interactive shell so the tmux session survives the agent exiting. -+// -+// ponytail: PATH handling matches the zellij unix path: PATH from cfg.Env is -+// exported last, after all other keys, so an explicit override takes effect. -+func buildLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { -+ path := cfg.Env["PATH"] -+ if path == "" { -+ path = getenv("PATH") -+ } -+ -+ var b strings.Builder -+ for _, key := range sortedKeys(cfg.Env) { -+ if key == "PATH" { -+ continue -+ } -+ b.WriteString("export ") -+ b.WriteString(key) -+ b.WriteString("=") -+ b.WriteString(shellQuote(cfg.Env[key])) -+ b.WriteString("; ") -+ } -+ if path != "" { -+ b.WriteString("export PATH=") -+ b.WriteString(shellQuote(path)) -+ b.WriteString("; ") -+ } -+ // Quote each argv word so spaces inside a word are preserved. -+ parts := make([]string, len(cfg.Argv)) -+ for i, a := range cfg.Argv { -+ parts[i] = shellQuote(a) -+ } -+ b.WriteString(strings.Join(parts, " ")) -+ // Keep the tmux session alive after the agent exits so the operator can -+ // inspect the terminal. The shell variable expansion picks up $SHELL from -+ // the process env if set, otherwise falls back to /bin/sh. -+ b.WriteString("; exec ${SHELL:-/bin/sh} -i") -+ return b.String() -+} -+ -+// -- error type -- -+ -+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 } -diff --git a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go -new file mode 100644 -index 0000000..7216fc4 ---- /dev/null -+++ b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go -@@ -0,0 +1,141 @@ -+package tmux -+ -+import ( -+ "context" -+ "os/exec" -+ "strings" -+ "testing" -+ "time" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" -+) -+ -+func TestRuntimeIntegration(t *testing.T) { -+ if _, err := exec.LookPath("tmux"); err != nil { -+ t.Skip("tmux unavailable") -+ } -+ -+ ctx := context.Background() -+ id := "ao_itest_tmux" -+ r := New(Options{Timeout: 5 * time.Second}) -+ -+ // Ensure clean slate: ignore errors (session may not exist). -+ _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) -+ -+ t.Cleanup(func() { -+ // Always destroy so a test failure never leaks a tmux session. -+ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) -+ }) -+ -+ h, err := r.Create(ctx, ports.RuntimeConfig{ -+ SessionID: "ao_itest_tmux", -+ WorkspacePath: t.TempDir(), -+ // Run a trivial command then drop into an interactive shell (the keep-alive -+ // exec is added by buildLaunchCommand, but we also verify here that output -+ // appears). -+ Argv: []string{"sh", "-c", "echo hello-from-tmux"}, -+ Env: map[string]string{"AO_SESSION_ID": id}, -+ }) -+ if err != nil { -+ t.Fatalf("Create: %v", err) -+ } -+ -+ alive, err := r.IsAlive(ctx, h) -+ if err != nil { -+ t.Fatalf("IsAlive: %v", err) -+ } -+ if !alive { -+ t.Fatal("alive = false, want true after create") -+ } -+ -+ // Wait for the echo output to appear (the session may take a moment to -+ // write it to the pane history). -+ out := waitForOutput(t, r, h, "hello-from-tmux", 5*time.Second) -+ if !strings.Contains(out, "hello-from-tmux") { -+ t.Fatalf("output = %q, want hello-from-tmux", out) -+ } -+ -+ // Send a command and verify it echoes back. -+ if err := r.SendMessage(ctx, h, "echo hello-send"); err != nil { -+ t.Fatalf("SendMessage: %v", err) -+ } -+ out = waitForOutput(t, r, h, "hello-send", 5*time.Second) -+ if !strings.Contains(out, "hello-send") { -+ t.Fatalf("output after SendMessage = %q, want hello-send", out) -+ } -+ -+ // Destroy and verify liveness goes false. -+ if err := r.Destroy(ctx, h); err != nil { -+ t.Fatalf("Destroy: %v", err) -+ } -+ alive, err = r.IsAlive(ctx, h) -+ if err != nil { -+ t.Fatalf("IsAlive after destroy: %v", err) -+ } -+ if alive { -+ t.Fatal("alive after destroy = true, want false") -+ } -+} -+ -+// TestRuntimeIntegrationExactSessionParsing verifies that IsAlive uses exact -+// session matching and does not treat a prefix as a live session. -+func TestRuntimeIntegrationExactSessionParsing(t *testing.T) { -+ if _, err := exec.LookPath("tmux"); err != nil { -+ t.Skip("tmux unavailable") -+ } -+ -+ ctx := context.Background() -+ longID := "ao_tmux_exact_long" -+ prefixID := "ao_tmux_exact" -+ -+ r := New(Options{Timeout: 5 * time.Second}) -+ _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) -+ _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID}) -+ -+ t.Cleanup(func() { -+ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: longID}) -+ _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: prefixID}) -+ }) -+ -+ h, err := r.Create(ctx, ports.RuntimeConfig{ -+ SessionID: "ao_tmux_exact_long", -+ WorkspacePath: t.TempDir(), -+ Argv: []string{"sh", "-c", "echo ready"}, -+ }) -+ if err != nil { -+ t.Fatalf("Create: %v", err) -+ } -+ -+ // tmux has-session -t should NOT match because tmux -+ // requires the exact session name when using -t with a plain string (not a -+ // glob). Verify by probing the prefix handle directly. -+ prefixAlive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID}) -+ if err != nil { -+ // tmux may return an error (session not found) rather than exit 0. -+ // That is acceptable here: the point is the prefix must not be alive. -+ t.Logf("IsAlive prefix returned error (acceptable): %v", err) -+ } -+ if prefixAlive { -+ _ = r.Destroy(ctx, h) -+ t.Fatal("prefix handle reported alive; tmux session matching is not exact") -+ } -+} -+ -+// waitForOutput polls GetOutput until out contains want or the deadline passes. -+func waitForOutput(t *testing.T, r *Runtime, h ports.RuntimeHandle, want string, deadline time.Duration) string { -+ t.Helper() -+ end := time.Now().Add(deadline) -+ var out string -+ for time.Now().Before(end) { -+ var err error -+ out, err = r.GetOutput(context.Background(), h, 50) -+ if err != nil { -+ t.Fatalf("GetOutput: %v", err) -+ } -+ if strings.Contains(out, want) { -+ return out -+ } -+ time.Sleep(100 * time.Millisecond) -+ } -+ return out -+} -diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go -new file mode 100644 -index 0000000..18c5e22 ---- /dev/null -+++ b/backend/internal/adapters/runtime/tmux/tmux_test.go -@@ -0,0 +1,630 @@ -+package tmux -+ -+import ( -+ "context" -+ "errors" -+ "os/exec" -+ "reflect" -+ "strings" -+ "testing" -+ "time" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/domain" -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" -+) -+ -+// -- fakeRunner test seam (mirrors zellij_test.go exactly) -- -+ -+type fakeRunner struct { -+ calls []runnerCall -+ outputs [][]byte -+ err error -+} -+ -+type runnerCall struct { -+ env []string -+ name string -+ args []string -+} -+ -+func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { -+ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) -+ var out []byte -+ if len(f.outputs) > 0 { -+ out = f.outputs[0] -+ f.outputs = f.outputs[1:] -+ } -+ if f.err != nil { -+ return out, f.err -+ } -+ return out, nil -+} -+ -+func (f *fakeRunner) Start(env []string, name string, args ...string) error { -+ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) -+ if len(f.outputs) > 0 { -+ f.outputs = f.outputs[1:] -+ } -+ return f.err -+} -+ -+// -- helpers -- -+ -+func newTestRuntime(chunkSize int) (*Runtime, *fakeRunner) { -+ fr := &fakeRunner{} -+ r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/sh", ChunkSize: chunkSize}) -+ r.runner = fr -+ return r, fr -+} -+ -+// -- Options / New tests -- -+ -+func TestNewDefaultsToPortableShell(t *testing.T) { -+ t.Setenv("SHELL", "") -+ r := New(Options{}) -+ if got := r.shell; got != "/bin/sh" { -+ t.Fatalf("default shell = %q, want /bin/sh", got) -+ } -+} -+ -+func TestNewPicksUpShellFromEnv(t *testing.T) { -+ t.Setenv("SHELL", "/bin/zsh") -+ r := New(Options{}) -+ if got := r.shell; got != "/bin/zsh" { -+ t.Fatalf("shell = %q, want /bin/zsh", got) -+ } -+} -+ -+// -- command builder tests -- -+ -+func TestCommandBuilders(t *testing.T) { -+ if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/sh", "echo hi; exec ${SHELL:-/bin/sh} -i"), -+ []string{"new-session", "-d", "-s", "sess-1", "-x", "220", "-y", "50", "-c", "/tmp/ws", "/bin/sh", "-c", "echo hi; exec ${SHELL:-/bin/sh} -i"}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("newSessionArgs = %#v, want %#v", got, want) -+ } -+ // set-option uses pane-targeting (no = prefix). -+ if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "sess-1", "status", "off"}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want) -+ } -+ // kill-session and has-session use exact-match prefix =. -+ if got, want := killSessionArgs("sess-1"), []string{"kill-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("killSessionArgs = %#v, want %#v", got, want) -+ } -+ if got, want := hasSessionArgs("sess-1"), []string{"has-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("hasSessionArgs = %#v, want %#v", got, want) -+ } -+ if got, want := sendKeysLiteralArgs("sess-1", "hello"), []string{"send-keys", "-t", "sess-1", "-l", "hello"}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("sendKeysLiteralArgs = %#v, want %#v", got, want) -+ } -+ if got, want := sendEnterArgs("sess-1"), []string{"send-keys", "-t", "sess-1", "Enter"}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("sendEnterArgs = %#v, want %#v", got, want) -+ } -+ if got, want := capturePaneArgs("sess-1", 10), []string{"capture-pane", "-t", "sess-1", "-p", "-S", "-10"}; !reflect.DeepEqual(got, want) { -+ t.Fatalf("capturePaneArgs = %#v, want %#v", got, want) -+ } -+} -+ -+// -- session name sanitization -- -+ -+func TestSessionNameSanitizesSpecialChars(t *testing.T) { -+ got, err := tmuxSessionName("repo/issue#42.1") -+ if err != nil { -+ t.Fatalf("tmuxSessionName: %v", err) -+ } -+ if !sessionIDPattern.MatchString(got) { -+ t.Fatalf("sanitized id %q fails pattern", got) -+ } -+ if !strings.HasPrefix(got, "repo-issue-42-1-") { -+ t.Fatalf("sanitized id = %q, want readable prefix", got) -+ } -+ if got == "repo/issue#42.1" { -+ t.Fatal("sanitized id still contains raw unsafe characters") -+ } -+} -+ -+func TestSessionNamePassesThroughShortConforming(t *testing.T) { -+ if got := SessionName("myproj-1"); got != "myproj-1" { -+ t.Fatalf("SessionName = %q, want unchanged", got) -+ } -+} -+ -+func TestSessionNameMatchesCreateNaming(t *testing.T) { -+ long := domain.SessionID(strings.Repeat("x", 60) + "-1") -+ viaCreate, err := tmuxSessionName(long) -+ if err != nil { -+ t.Fatalf("tmuxSessionName: %v", err) -+ } -+ if got := SessionName(string(long)); got != viaCreate { -+ t.Fatalf("SessionName = %q, but Create uses %q", got, viaCreate) -+ } -+ if SessionName(string(long)) == string(long) { -+ t.Fatal("expected long id to be sanitised to a different name") -+ } -+} -+ -+// -- env key validation -- -+ -+func TestCreateRejectsInvalidEnvKeys(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ _ = fr -+ _, err := r.Create(context.Background(), ports.RuntimeConfig{ -+ SessionID: "sess-1", -+ WorkspacePath: "/tmp/ws", -+ Argv: []string{"echo", "hi"}, -+ Env: map[string]string{"BAD KEY": "x"}, -+ }) -+ if err == nil || !strings.Contains(err.Error(), "invalid env key") { -+ t.Fatalf("Create err = %v, want invalid env key", err) -+ } -+} -+ -+// -- Create tests -- -+ -+func TestCreateIssuesNewSessionAndStatusOff(t *testing.T) { -+ // new-session output (ignored), set-option output, has-session output (exit 0 = alive) -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{nil, nil, nil} -+ -+ h, err := r.Create(context.Background(), ports.RuntimeConfig{ -+ SessionID: "sess-1", -+ WorkspacePath: "/tmp/ws", -+ Argv: []string{"echo", "hi"}, -+ Env: map[string]string{"AO_SESSION_ID": "sess-1"}, -+ }) -+ if err != nil { -+ t.Fatalf("Create: %v", err) -+ } -+ if h.ID != "sess-1" { -+ t.Fatalf("handle ID = %q, want sess-1", h.ID) -+ } -+ // Expect 3 calls: new-session, set-option, has-session. -+ if len(fr.calls) != 3 { -+ t.Fatalf("calls = %d, want 3", len(fr.calls)) -+ } -+ -+ // Call 0: new-session -+ if got := fr.calls[0].args[0]; got != "new-session" { -+ t.Fatalf("call[0] = %q, want new-session", got) -+ } -+ // Check -s , -c are present. -+ joined := strings.Join(fr.calls[0].args, " ") -+ if !strings.Contains(joined, "-s sess-1") { -+ t.Fatalf("new-session args missing -s sess-1: %v", fr.calls[0].args) -+ } -+ if !strings.Contains(joined, "-c /tmp/ws") { -+ t.Fatalf("new-session args missing -c /tmp/ws: %v", fr.calls[0].args) -+ } -+ // Ensure -x and -y are set. -+ if !strings.Contains(joined, "-x 220") || !strings.Contains(joined, "-y 50") { -+ t.Fatalf("new-session args missing -x/-y: %v", fr.calls[0].args) -+ } -+ -+ // Call 1: set-option status off (plain target, pane-targeting does not use =). -+ if got, want := fr.calls[1].args, setStatusOffArgs("sess-1"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("call[1] = %#v, want %#v", got, want) -+ } -+ -+ // Call 2: has-session (IsAlive, uses exact-match target =sess-1). -+ if got, want := fr.calls[2].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("call[2] = %#v, want %#v", got, want) -+ } -+} -+ -+func TestCreateLaunchCommandContainsKeepAliveShell(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{nil, nil, nil} -+ -+ _, err := r.Create(context.Background(), ports.RuntimeConfig{ -+ SessionID: "sess-1", -+ WorkspacePath: "/tmp/ws", -+ Argv: []string{"myagent", "--flag"}, -+ }) -+ if err != nil { -+ t.Fatalf("Create: %v", err) -+ } -+ // The launch command is the last argument to new-session (after shellPath -c). -+ args := fr.calls[0].args -+ launchCmd := args[len(args)-1] -+ if !strings.Contains(launchCmd, "exec ${SHELL:-/bin/sh} -i") { -+ t.Fatalf("launch command missing keep-alive shell: %q", launchCmd) -+ } -+ if !strings.Contains(launchCmd, "'myagent'") { -+ t.Fatalf("launch command missing quoted argv: %q", launchCmd) -+ } -+} -+ -+func TestCreateLaunchCommandExportsEnvVars(t *testing.T) { -+ oldGetenv := getenv -+ getenv = func(key string) string { -+ if key == "PATH" { -+ return "/usr/bin:/bin" -+ } -+ return "" -+ } -+ defer func() { getenv = oldGetenv }() -+ -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{nil, nil, nil} -+ -+ _, err := r.Create(context.Background(), ports.RuntimeConfig{ -+ SessionID: "sess-1", -+ WorkspacePath: "/tmp/ws", -+ Argv: []string{"myagent"}, -+ Env: map[string]string{ -+ "AO_SESSION_ID": "sess-1", -+ "ODD": "can't", -+ "PATH": "/custom/bin:/usr/bin", -+ }, -+ }) -+ if err != nil { -+ t.Fatalf("Create: %v", err) -+ } -+ args := fr.calls[0].args -+ launchCmd := args[len(args)-1] -+ for _, want := range []string{ -+ "export AO_SESSION_ID='sess-1';", -+ "export ODD='can'\\''t';", -+ "export PATH='/custom/bin:/usr/bin';", -+ } { -+ if !strings.Contains(launchCmd, want) { -+ t.Fatalf("launch command missing %q in: %q", want, launchCmd) -+ } -+ } -+} -+ -+func TestCreateDestroysAndReturnsErrorWhenNotAlive(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ // new-session ok, set-option ok, has-session fails (not alive) -+ fr.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} -+ fr.err = nil -+ // Override: make has-session return ExitError. -+ // We need has-session to fail to simulate the session not being alive. -+ // Use a different approach: make the third call return an ExitError. -+ r2, fr2 := newTestRuntime(0) -+ fr2.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} -+ // We need the has-session to return exitErr + the output. But fakeRunner -+ // returns the same err for all calls. Set a custom err only for the third call -+ // by using a custom fake. -+ _ = r -+ _ = fr -+ -+ // Use a specialized fakeRunner that returns an exit error only for the 3rd call. -+ fr3 := &fakeRunnerSelectiveErr{exitErrAt: 2} -+ fr3.outputs = [][]byte{nil, nil, []byte("can't find session: sess-1")} -+ r2.runner = fr3 -+ -+ _, err := r2.Create(context.Background(), ports.RuntimeConfig{ -+ SessionID: "sess-1", -+ WorkspacePath: "/tmp/ws", -+ Argv: []string{"myagent"}, -+ }) -+ if err == nil { -+ t.Fatal("Create: got nil, want error when session not alive after create") -+ } -+ // Verify Destroy was called (kill-session). -+ hasKill := false -+ for _, c := range fr3.calls { -+ if len(c.args) > 0 && c.args[0] == "kill-session" { -+ hasKill = true -+ } -+ } -+ if !hasKill { -+ t.Fatal("expected kill-session cleanup call when session not alive") -+ } -+} -+ -+// fakeRunnerSelectiveErr returns an exec.ExitError for the call at index exitErrAt. -+type fakeRunnerSelectiveErr struct { -+ calls []runnerCall -+ outputs [][]byte -+ exitErrAt int -+} -+ -+func (f *fakeRunnerSelectiveErr) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { -+ idx := len(f.calls) -+ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) -+ var out []byte -+ if len(f.outputs) > 0 { -+ out = f.outputs[0] -+ f.outputs = f.outputs[1:] -+ } -+ if idx == f.exitErrAt { -+ return out, &exec.ExitError{} -+ } -+ return out, nil -+} -+ -+func (f *fakeRunnerSelectiveErr) Start(env []string, name string, args ...string) error { -+ f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) -+ if len(f.outputs) > 0 { -+ f.outputs = f.outputs[1:] -+ } -+ return nil -+} -+ -+// -- Destroy tests -- -+ -+func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("can't find session: sess-1")} -+ fr.err = &exec.ExitError{} -+ -+ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { -+ t.Fatalf("Destroy: %v", err) -+ } -+ if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" { -+ t.Fatalf("calls = %#v, want only kill-session", fr.calls) -+ } -+} -+ -+func TestDestroyIsIdempotentWhenNoServer(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("no server running on /tmp/tmux-1000/default")} -+ fr.err = &exec.ExitError{} -+ -+ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { -+ t.Fatalf("Destroy no-server: %v", err) -+ } -+} -+ -+func TestDestroyReportsUnexpectedFailures(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("permission denied")} -+ fr.err = &exec.ExitError{} -+ -+ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err == nil { -+ t.Fatal("Destroy: got nil, want unexpected failure error") -+ } -+} -+ -+func TestDestroyArgs(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{nil} -+ -+ if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil { -+ t.Fatalf("Destroy: %v", err) -+ } -+ // killSessionArgs uses exact-match target =. -+ if got, want := fr.calls[0].args, killSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("destroy args = %#v, want %#v", got, want) -+ } -+} -+ -+// -- IsAlive tests -- -+ -+func TestIsAliveReturnsTrueOnExitZero(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{nil} -+ -+ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) -+ if err != nil { -+ t.Fatalf("IsAlive: %v", err) -+ } -+ if !alive { -+ t.Fatal("alive = false, want true") -+ } -+ if got, want := fr.calls[0].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("has-session args = %#v, want %#v", got, want) -+ } -+} -+ -+func TestIsAliveReturnsFalseNilOnCantFindSession(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("can't find session: sess-1")} -+ fr.err = &exec.ExitError{} -+ -+ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) -+ if err != nil { -+ t.Fatalf("IsAlive: %v", err) -+ } -+ if alive { -+ t.Fatal("alive = true, want false") -+ } -+} -+ -+func TestIsAliveReturnsFalseNilOnNoServer(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("no server running on /tmp/tmux-1000/default")} -+ fr.err = &exec.ExitError{} -+ -+ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) -+ if err != nil { -+ t.Fatalf("IsAlive: %v", err) -+ } -+ if alive { -+ t.Fatal("alive = true, want false") -+ } -+} -+ -+func TestIsAliveReturnsFalseNilOnErrorConnecting(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("error connecting to /tmp/tmux-1000/default (No such file or directory)")} -+ fr.err = &exec.ExitError{} -+ -+ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) -+ if err != nil { -+ t.Fatalf("IsAlive error connecting: %v", err) -+ } -+ if alive { -+ t.Fatal("alive = true, want false") -+ } -+} -+ -+// IsAlive must treat any non-"missing" non-zero exit as a probe error so the -+// reaper never reads a transient failure as proof of death. -+func TestIsAliveReportsOtherExitFailuresAsProbeErrors(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("unexpected internal error")} -+ fr.err = &exec.ExitError{} -+ -+ alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1"}) -+ if err == nil { -+ t.Fatal("IsAlive: got nil, want probe error — failed probe must not read as dead") -+ } -+ if alive { -+ t.Fatal("alive = true on probe failure") -+ } -+} -+ -+// -- SendMessage tests -- -+ -+func TestSendMessageChunksAndSendsEnter(t *testing.T) { -+ r, fr := newTestRuntime(5) // chunkSize=5 -+ // "hello世界": hello=5 bytes, 世=3 bytes, 界=3 bytes => 3 sends + 1 Enter -+ if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, "hello世界"); err != nil { -+ t.Fatalf("SendMessage: %v", err) -+ } -+ if len(fr.calls) != 4 { -+ t.Fatalf("calls = %d, want 4 (3 chunks + Enter)", len(fr.calls)) -+ } -+ if got, want := fr.calls[0].args, sendKeysLiteralArgs("sess-1", "hello"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("chunk 1 args = %#v, want %#v", got, want) -+ } -+ if got, want := fr.calls[1].args, sendKeysLiteralArgs("sess-1", "世"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("chunk 2 args = %#v, want %#v", got, want) -+ } -+ if got, want := fr.calls[2].args, sendKeysLiteralArgs("sess-1", "界"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("chunk 3 args = %#v, want %#v", got, want) -+ } -+ if got, want := fr.calls[3].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) { -+ t.Fatalf("Enter args = %#v, want %#v", got, want) -+ } -+} -+ -+func TestSendMessageUsesLiteralFlag(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, "Enter"); err != nil { -+ t.Fatalf("SendMessage: %v", err) -+ } -+ // First call must use -l so "Enter" is sent literally, not as a key binding. -+ if fr.calls[0].args[3] != "-l" { -+ t.Fatalf("send-keys args[3] = %q, want -l", fr.calls[0].args[3]) -+ } -+} -+ -+// -- GetOutput tests -- -+ -+func TestGetOutputValidatesLines(t *testing.T) { -+ r, _ := newTestRuntime(0) -+ _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 0) -+ if err == nil { -+ t.Fatal("GetOutput lines=0: got nil, want error") -+ } -+} -+ -+func TestGetOutputTrimsLines(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("one\ntwo\nthree\n")} -+ -+ out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 2) -+ if err != nil { -+ t.Fatalf("GetOutput: %v", err) -+ } -+ if out != "two\nthree\n" { -+ t.Fatalf("output = %q, want last two lines", out) -+ } -+} -+ -+func TestGetOutputTrimsTrailingScreenPaddingBeforeTailing(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("ready\nprompt> echo hi\nhi\n\n\n\n")} -+ -+ out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 2) -+ if err != nil { -+ t.Fatalf("GetOutput: %v", err) -+ } -+ if out != "prompt> echo hi\nhi\n" { -+ t.Fatalf("output = %q, want last non-padding lines", out) -+ } -+} -+ -+func TestGetOutputArgs(t *testing.T) { -+ r, fr := newTestRuntime(0) -+ fr.outputs = [][]byte{[]byte("output\n")} -+ -+ _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1"}, 10) -+ if err != nil { -+ t.Fatalf("GetOutput: %v", err) -+ } -+ if got, want := fr.calls[0].args, capturePaneArgs("sess-1", 10); !reflect.DeepEqual(got, want) { -+ t.Fatalf("capture-pane args = %#v, want %#v", got, want) -+ } -+} -+ -+// -- AttachCommand tests -- -+ -+func TestAttachCommandReturnsExpectedArgv(t *testing.T) { -+ r := New(Options{Binary: "/usr/bin/tmux", Timeout: time.Second}) -+ argv, env, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1"}) -+ if err != nil { -+ t.Fatalf("AttachCommand: %v", err) -+ } -+ want := []string{"/usr/bin/tmux", "attach-session", "-t", "sess-1"} -+ if !reflect.DeepEqual(argv, want) { -+ t.Fatalf("argv = %#v, want %#v", argv, want) -+ } -+ if env != nil { -+ t.Fatalf("env = %#v, want nil", env) -+ } -+} -+ -+func TestAttachCommandRejectsInvalidHandle(t *testing.T) { -+ r := New(Options{}) -+ _, _, err := r.AttachCommand(ports.RuntimeHandle{ID: ""}) -+ if err == nil { -+ t.Fatal("AttachCommand empty handle: got nil, want error") -+ } -+} -+ -+// -- commandError tests -- -+ -+func TestCommandErrorUnwraps(t *testing.T) { -+ base := errors.New("base") -+ err := commandError{err: base, output: "details"} -+ if !errors.Is(err, base) { -+ t.Fatal("commandError should unwrap base error") -+ } -+ if !strings.Contains(err.Error(), "details") { -+ t.Fatalf("error = %q, want output details", err.Error()) -+ } -+} -+ -+// -- text helper tests -- -+ -+func TestChunks(t *testing.T) { -+ if got := chunks("", 5); !reflect.DeepEqual(got, []string{""}){ -+ t.Fatalf("chunks empty = %#v", got) -+ } -+ if got := chunks("hello", 10); !reflect.DeepEqual(got, []string{"hello"}) { -+ t.Fatalf("chunks fits = %#v", got) -+ } -+ // UTF-8 boundary: 世 is 3 bytes; with chunkSize=5 "hello世界" splits at 5,6,6 -+ got := chunks("hello世界", 5) -+ if len(got) != 3 { -+ t.Fatalf("chunks count = %d, want 3: %#v", len(got), got) -+ } -+ if got[0] != "hello" || got[1] != "世" || got[2] != "界" { -+ t.Fatalf("chunks = %#v, want [hello 世 界]", got) -+ } -+} -+ -+func TestTailLines(t *testing.T) { -+ if got := tailLines("a\nb\nc\n", 2); got != "b\nc\n" { -+ t.Fatalf("tailLines = %q, want b/c", got) -+ } -+ if got := tailLines("a\nb\n", 5); got != "a\nb\n" { -+ t.Fatalf("tailLines fewer = %q", got) -+ } -+ if got := tailLines("", 5); got != "" { -+ t.Fatalf("tailLines empty = %q", got) -+ } -+} -+ -+func TestTrimTrailingBlankLines(t *testing.T) { -+ if got := trimTrailingBlankLines("a\nb\n\n\n"); got != "a\nb\n" { -+ t.Fatalf("trimTrailingBlankLines = %q, want a/b", got) -+ } -+ if got := trimTrailingBlankLines(""); got != "" { -+ t.Fatalf("trimTrailingBlankLines empty = %q", got) -+ } -+} - ---- Changes --- - -.superpowers/sdd/task-1-brief.md - @@ -0,0 +1,141 @@ - +# Task 1: tmux runtime adapter - + - +## Goal - +Create a new Go package `internal/adapters/runtime/tmux` that drives agent - +sessions through the **tmux** CLI, implementing the SAME method set the existing - +zellij adapter exposes, so it is a drop-in replacement on Darwin/Linux. This task - +adds the package and its tests ONLY. It does NOT wire it into the daemon and does - +NOT delete zellij (that is Task 2). The build must stay green. - + - +Module path: `github.com/aoagents/agent-orchestrator/backend` - +Work inside: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode/backend` - + - +## The template to mirror - +The zellij adapter is your structural template. READ THESE FIRST: - +- `internal/adapters/runtime/zellij/zellij.go` (Runtime struct, Options, New, the - + `runner` interface + `execRunner`, Create/Destroy/IsAlive/SendMessage/GetOutput/ - + AttachCommand, session-name sanitization, `chunks`, `tailLines`, - + `trimTrailingBlankLines`, `validateEnvKeys`, `sortedKeys`, `commandError`). - +- `internal/adapters/runtime/zellij/commands.go` (arg builders + shell quoting). - +- `internal/adapters/runtime/zellij/zellij_test.go` (the `fakeRunner` test seam: - + `type fakeRunner struct { calls []runnerCall; outputs [][]byte; err error }`, - + injected via `r.runner = &fakeRunner{...}`). Mirror this exactly for tmux tests. - + - +Reuse the same idioms (Go style, comment density, error wrapping `fmt.Errorf("tmux - +runtime: ...: %w", err)`). Match the surrounding code. - + - +## Interface the package must satisfy - +The package's `Runtime` type must implement, with these EXACT signatures (they are - +the existing consumer contracts in the repo — verify against `internal/ports/ - +outbound.go`, `internal/terminal/attachment.go` `PTYSource`, `internal/review/ - +launcher.go` `reviewerRuntime`, `internal/daemon/lifecycle_wiring.go` - +`runtimeMessageSender`): - + - +```go - +func New(opts Options) *Runtime - +func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) - +func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error - +func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) - +func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error - +func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) - +func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) - +``` - + - +Add a compile-time assertion: `var _ ports.Runtime = (*Runtime)(nil)`. - + - +`ports.RuntimeConfig` fields: `SessionID domain.SessionID`, `WorkspacePath string`, - +`Argv []string`, `Env map[string]string`. `ports.RuntimeHandle{ ID string }`. - + - +## tmux command mapping (the substance) - +Use a `runner` interface identical in shape to zellij's (`Run(ctx, env, name, - +args...) ([]byte, error)` and `Start(env, name, args...) error`) with an - +`execRunner` default, so tests inject a `fakeRunner`. The tmux binary defaults to - +`"tmux"` (allow `Options.Binary` override; resolve via `exec.LookPath`). - + - +- **Create**: validate session id (sanitize like zellij's `SessionName`; tmux - + session names must not contain `.` or `:` — those are window/pane separators — - + and must be non-empty; reuse the same sanitize-to-`[A-Za-z0-9_-]`+hash approach). - + Validate WorkspacePath non-empty, Argv non-empty, env keys (port - + `validateEnvKeys`). Then: - + `tmux new-session -d -s -x 220 -y 50 -c ` - + where `` runs the agent then **execs a keep-alive shell so the tmux - + session survives the agent exiting** (this is the whole reason a multiplexer is - + used). Build the launch as a single shell command string passed to - + `sh -c` (or the configured shell), of the form: - + `export K=V; ...; export PATH=...; ; exec "${SHELL:-/bin/sh}" -i` - + Reuse zellij's `wrapLaunchCommandUnix` quoting approach (single-quote each env - + value and each argv word). Pass env to the agent via the exported-vars prefix - + (do NOT rely on tmux `-e`, which has its own quirks). After creating, set - + `tmux set-option -t status off` (hide the status bar in the embedded web - + terminal). Then verify liveness via IsAlive; on failure, Destroy and return an - + error (mirror zellij's create cleanup discipline). Return - + `ports.RuntimeHandle{ID: }`. NOTE: tmux needs no pane-id discovery — the - + handle is just the session id. Keep the handle format simple (the session id); - + do NOT invent a `session/pane` split unless a consumer requires it (none does — - + verify). - +- **Destroy**: `tmux kill-session -t `. Treat "session not found"/"can't find - + session" stderr on a non-zero exit as success (idempotent), mirroring zellij's - + `deleteSessionMissingOutput`. - +- **IsAlive**: `tmux has-session -t `. Exit 0 => alive. A non-zero exit whose - + output says the session/server is missing ("can't find session", "no server - + running", "error connecting") => definitively `false, nil`. Any OTHER error => - + `false, err` (a probe failure, NOT proof of death) — this distinction matters - + because the reaper feeds the lifecycle manager and must not kill a session on a - + transient probe error. Mirror zellij's IsAlive contract precisely. - +- **SendMessage**: send literal text then Enter. Use - + `tmux send-keys -t -l ` for the literal text (the `-l` flag stops - + tmux interpreting words like "Enter"/"C-c" as key names), chunked via the ported - + `chunks(message, chunkSize)` helper, then `tmux send-keys -t Enter` to - + submit. (Per the agent-orchestrator reference, multiline text can alternatively - + go via `load-buffer -`/`paste-buffer`, but `send-keys -l` chunked is simpler and - + correct — use it. Mark any simplification with a `ponytail:` comment.) - +- **GetOutput**: `tmux capture-pane -t -p -S -` (the `-S -` starts n - + lines back in history; `-p` prints to stdout). Then apply the ported - + `trimTrailingBlankLines` + `tailLines(out, lines)`. `lines <= 0` => error, like - + zellij. - +- **AttachCommand**: return argv `["tmux", "attach-session", "-t", ]` (use the - + resolved binary path as argv[0]) and a `nil` env block (no per-session socket dir - + needed for tmux — unlike zellij's Windows ConPTY env). The terminal layer's - + existing Unix `defaultSpawn` (creack/pty) will spawn this unchanged. - + - ... (41 lines truncated) - +141 -0 - -.superpowers/sdd/task-1-report.md - @@ -0,0 +1,65 @@ - +# Task 1 Report: tmux Runtime Adapter - + - +## What was built - + - +New package `backend/internal/adapters/runtime/tmux` implementing `ports.Runtime` - +via the tmux CLI. Four files were created (no existing files modified): - + - +- `backend/internal/adapters/runtime/tmux/tmux.go` - Runtime struct, Options, New, - + Create/Destroy/IsAlive/SendMessage/GetOutput/AttachCommand, session-name - + sanitization, helpers (chunks, tailLines, trimTrailingBlankLines, - + validateEnvKeys, sortedKeys, shellQuote, buildLaunchCommand, commandError). - +- `backend/internal/adapters/runtime/tmux/commands.go` - Arg builders for all tmux - + subcommands (newSessionArgs, setStatusOffArgs, killSessionArgs, hasSessionArgs, - + sendKeysLiteralArgs, sendEnterArgs, capturePaneArgs, exactSessionTarget). - +- `backend/internal/adapters/runtime/tmux/tmux_test.go` - 32 unit tests via fakeRunner. - +- `backend/internal/adapters/runtime/tmux/tmux_integration_test.go` - 2 integration - + tests gated on `exec.LookPath("tmux")`. - + - +## Design choices - + - +1. Handle format: plain session id string (no session/pane split). tmux needs no - + pane-id discovery; the handle is just `ports.RuntimeHandle{ID: }`. - + - +2. Exact session targeting: `kill-session -t =` and `has-session -t =` use - + tmux's `=` exact-name prefix (supported by session-selection commands in tmux - + 3.x) to prevent prefix matching ("foo" matching "foobar"). Commands that use - + pane-targeting syntax (set-option, send-keys, capture-pane) use a plain session - + name because they do not support the `=` prefix. - + - +3. Keep-alive shell: `buildLaunchCommand` appends `; exec ${SHELL:-/bin/sh} -i` so - + the tmux session survives agent exit (the whole reason a multiplexer is used). - + - +4. Send-keys chunking: `send-keys -t -l ` with `-l` flag sends text - + literally (tmux does not interpret "Enter", "C-c", etc. as key names). Chunked - + via ported `chunks()` helper with 16 KB default. - + - +5. `sessionMissingOutput` covers: "can't find session", "no server running", - + "error connecting", "session not found". Both `killSessionMissingOutput` and the - + IsAlive path use this to distinguish definitive-dead from probe-error. - + - +6. `AttachCommand` returns `["tmux", "attach-session", "-t", id]` with nil env - + (no per-session socket dir needed unlike zellij's Windows path). - + - +7. `ponytail:` comments mark the two deliberate simplifications: - + - send-keys -l chunked vs. load-buffer/paste-buffer (ceiling: very large - + messages are slightly slower; 16 KB default is ample for agent prompts). - + - PATH handling matches the zellij unix path. - + - +## Verification output - + - +``` - +$ cd backend && go build ./... - +# success (no output) - + - +$ go test ./internal/adapters/runtime/tmux/... -v - +# 34 tests passed (32 unit + 2 integration with real tmux 3.6b) - + - +$ go vet ./internal/adapters/runtime/tmux/... - +# no issues - +``` - + - +## Concerns - + - +None. The build is green, all 34 tests pass (including the integration tests on - +the installed tmux 3.6b), and no files outside the new package were modified. - +65 -0 - -backend/internal/adapters/runtime/tmux/commands.go - @@ -0,0 +1,64 @@ - +package tmux - + - +import "fmt" - + - +// newSessionArgs builds args for `tmux new-session -d -s -x 220 -y 50 - +// -c -c `. The shell -c form runs the launch command - +// inside the configured shell so exported env vars and quoting work correctly. - +func newSessionArgs(id, cwd, shellPath, launchCmd string) []string { - + return []string{ - + "new-session", "-d", - + "-s", id, - + "-x", "220", - + "-y", "50", - + "-c", cwd, - + shellPath, "-c", launchCmd, - + } - +} - + - +// setStatusOffArgs hides the tmux status bar for the given session. - +// set-option uses pane-targeting syntax which does not accept the `=` prefix, - +// so we pass the session name directly. - +func setStatusOffArgs(id string) []string { - + return []string{"set-option", "-t", id, "status", "off"} - +} - + - +// killSessionArgs builds args for `tmux kill-session -t =`. The `=` prefix - +// requests exact-name matching so a session "foo" does not accidentally match - +// "foobar" (tmux otherwise does unique-prefix matching). - +func killSessionArgs(id string) []string { - + return []string{"kill-session", "-t", exactSessionTarget(id)} - +} - + - +// hasSessionArgs builds args for `tmux has-session -t =`. The `=` prefix - +// requests exact-name matching (see killSessionArgs). - +func hasSessionArgs(id string) []string { - + return []string{"has-session", "-t", exactSessionTarget(id)} - +} - + - +// exactSessionTarget wraps id in tmux's exact-match prefix `=` so session- - +// selection commands (-t) target only the session with that precise name. - +// Only kill-session and has-session support this prefix; pane-targeting - +// commands (send-keys, capture-pane, set-option) use a plain session name. - +func exactSessionTarget(id string) string { - + return "=" + id - +} - + - +// sendKeysLiteralArgs builds args for `tmux send-keys -t -l `. - +// The -l flag stops tmux interpreting words like "Enter" as key names so the - +// text is sent verbatim. - +func sendKeysLiteralArgs(id, chunk string) []string { - + return []string{"send-keys", "-t", id, "-l", chunk} - +} - + - +// sendEnterArgs builds args for `tmux send-keys -t Enter` to submit the - +// queued input. - +func sendEnterArgs(id string) []string { - + return []string{"send-keys", "-t", id, "Enter"} - +} - + - +// capturePaneArgs builds args for `tmux capture-pane -t -p -S -`. - +// -p prints to stdout; -S - starts n lines back in history. - +func capturePaneArgs(id string, lines int) []string { - + return []string{"capture-pane", "-t", id, "-p", "-S", fmt.Sprintf("-%d", lines)} - +} - +64 -0 - -backend/internal/adapters/runtime/tmux/tmux.go - @@ -0,0 +1,482 @@ - +// Package tmux implements ports.Runtime using tmux sessions on Darwin/Linux. - +package tmux - + - +import ( - + "context" - + "crypto/sha256" - + "encoding/hex" - + "errors" - + "fmt" - + "os" - + "os/exec" - + "regexp" - + "sort" - + "strings" - + "time" - + "unicode/utf8" - + - + "github.com/aoagents/agent-orchestrator/backend/internal/domain" - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - +) - + - +const ( - + defaultTimeout = 5 * time.Second - + defaultChunkBytes = 16 * 1024 - +) - + - +var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - + - +var getenv = os.Getenv - + - +// Options configures a tmux Runtime. Every field has a sensible default (see - +// New), so the zero value is usable. - +type Options struct { - + Binary string // default "tmux" (resolved via exec.LookPath) - + Shell string // default $SHELL else /bin/sh - + Timeout time.Duration // default 5s - + ChunkSize int // default 16*1024 - +} - + - +// Runtime runs agent sessions inside tmux sessions, driving them via the tmux - +// CLI. It implements ports.Runtime. - +type Runtime struct { - + binary string - + shell string - + timeout time.Duration - + chunkSize int - + runner runner - +} - + - +var _ ports.Runtime = (*Runtime)(nil) - + - +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 = append(append([]string(nil), os.Environ()...), env...) - + return cmd.CombinedOutput() - +} - + - +func (execRunner) Start(env []string, name string, args ...string) error { - + cmd := exec.Command(name, args...) - + cmd.Env = append(append([]string(nil), os.Environ()...), env...) - + return cmd.Start() - +} - + - +// New builds a tmux Runtime, filling unset Options with defaults: binary "tmux" - +// (resolved via exec.LookPath), shell from $SHELL (else /bin/sh), and the - +// default timeout and output chunk size. - +func New(opts Options) *Runtime { - + binary := opts.Binary - + if binary == "" { - + if path, err := exec.LookPath("tmux"); err == nil { - + binary = path - + } else { - + binary = "tmux" - + } - + } - + timeout := opts.Timeout - + if timeout == 0 { - + timeout = defaultTimeout - + } - + shellPath := opts.Shell - + if shellPath == "" { - + shellPath = getenv("SHELL") - + } - + if shellPath == "" { - + shellPath = "/bin/sh" - + } - + chunkSize := opts.ChunkSize - + if chunkSize <= 0 { - + chunkSize = defaultChunkBytes - + } - + return &Runtime{ - + binary: binary, - + shell: shellPath, - ... (382 lines truncated) - +482 -0 - -backend/internal/adapters/runtime/tmux/tmux_integration_test.go - @@ -0,0 +1,141 @@ - +package tmux - + - +import ( - + "context" - + "os/exec" - + "strings" - + "testing" - + "time" - + - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - +) - + - +func TestRuntimeIntegration(t *testing.T) { - + if _, err := exec.LookPath("tmux"); err != nil { - + t.Skip("tmux unavailable") - + } - + - + ctx := context.Background() - + id := "ao_itest_tmux" - + r := New(Options{Timeout: 5 * time.Second}) - + - + // Ensure clean slate: ignore errors (session may not exist). - + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) - + - + t.Cleanup(func() { - + // Always destroy so a test failure never leaks a tmux session. - + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) - + }) - + - + h, err := r.Create(ctx, ports.RuntimeConfig{ - + SessionID: "ao_itest_tmux", - + WorkspacePath: t.TempDir(), - + // Run a trivial command then drop into an interactive shell (the keep-alive - + // exec is added by buildLaunchCommand, but we also verify here that output - + // appears). - + Argv: []string{"sh", "-c", "echo hello-from-tmux"}, - + Env: map[string]string{"AO_SESSION_ID": id}, - + }) - + if err != nil { - + t.Fatalf("Create: %v", err) - + } - + - + alive, err := r.IsAlive(ctx, h) - + if err != nil { - + t.Fatalf("IsAlive: %v", err) - + } - + if !alive { - + t.Fatal("alive = false, want true after create") - + } - + - + // Wait for the echo output to appear (the session may take a moment to - + // write it to the pane history). - + out := waitForOutput(t, r, h, "hello-from-tmux", 5*time.Second) - + if !strings.Contains(out, "hello-from-tmux") { - + t.Fatalf("output = %q, want hello-from-tmux", out) - + } - + - + // Send a command and verify it echoes back. - + if err := r.SendMessage(ctx, h, "echo hello-send"); err != nil { - + t.Fatalf("SendMessage: %v", err) - + } - + out = waitForOutput(t, r, h, "hello-send", 5*time.Second) - + if !strings.Contains(out, "hello-send") { - + t.Fatalf("output after SendMessage = %q, want hello-send", out) - + } - + - + // Destroy and verify liveness goes false. - + if err := r.Destroy(ctx, h); err != nil { - + t.Fatalf("Destroy: %v", err) - + } - + alive, err = r.IsAlive(ctx, h) - + if err != nil { - + t.Fatalf("IsAlive after destroy: %v", err) - + } - + if alive { - + t.Fatal("alive after destroy = true, want false") - + } - +} - + - +// TestRuntimeIntegrationExactSessionParsing verifies that IsAlive uses exact - +// session matching and does not treat a prefix as a live session. - +func TestRuntimeIntegrationExactSessionParsing(t *testing.T) { - + if _, err := exec.LookPath("tmux"); err != nil { - + t.Skip("tmux unavailable") - + } - + - + ctx := context.Background() - + longID := "ao_tmux_exact_long" - + prefixID := "ao_tmux_exact" - + - + r := New(Options{Timeout: 5 * time.Second}) - + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) - + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID}) - + - + t.Cleanup(func() { - + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: longID}) - + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: prefixID}) - + }) - + - + h, err := r.Create(ctx, ports.RuntimeConfig{ - ... (41 lines truncated) - +141 -0 - -backend/internal/adapters/runtime/tmux/tmux_test.go - @@ -0,0 +1,630 @@ - +package tmux - + - +import ( - + "context" - + "errors" - + "os/exec" - + "reflect" - + "strings" - + "testing" - + "time" - + - + "github.com/aoagents/agent-orchestrator/backend/internal/domain" - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - +) - + - +// -- fakeRunner test seam (mirrors zellij_test.go exactly) -- - + - +type fakeRunner struct { - + calls []runnerCall - + outputs [][]byte - + err error - +} - + - +type runnerCall struct { - + env []string - + name string - + args []string - +} - + - +func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { - + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) - + var out []byte - + if len(f.outputs) > 0 { - + out = f.outputs[0] - + f.outputs = f.outputs[1:] - + } - + if f.err != nil { - + return out, f.err - + } - + return out, nil - +} - + - +func (f *fakeRunner) Start(env []string, name string, args ...string) error { - + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) - + if len(f.outputs) > 0 { - + f.outputs = f.outputs[1:] - + } - + return f.err - +} - + - +// -- helpers -- - -... (more changes truncated) - +51 -0 -[full diff: rtk git diff --no-compact] diff --git a/.superpowers/sdd/task-2-brief.md b/.superpowers/sdd/task-2-brief.md deleted file mode 100644 index 16c23870..00000000 --- a/.superpowers/sdd/task-2-brief.md +++ /dev/null @@ -1,142 +0,0 @@ -# Task 2: runtime selection + daemon wiring + doctor/spawn hints - -## Goal -Wire the new tmux adapter (Task 1, package `internal/adapters/runtime/tmux`) into -the daemon by platform: select **tmux on Darwin/Linux**, keep **zellij on Windows** -(interim — the Windows ConPTY adapter is a later phase). Keep the build and the -whole test suite green on this Darwin machine, and keep `GOOS=windows go build` -compiling. Do NOT delete the zellij package (Windows still uses it). - -Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, -module in `backend/`. Branch `migrate-zellij-to-tmux-conpty` is checked out. -Module path prefix: `github.com/aoagents/agent-orchestrator/backend`. - -## Background you need (verified facts) -Every runtime consumer already depends on a NARROW structural interface, so they -need no change: -- `internal/terminal/attachment.go` `PTYSource` = AttachCommand + IsAlive -- `internal/daemon/lifecycle_wiring.go` `runtimeMessageSender` = SendMessage -- `internal/observe/reaper/reaper.go` `runtimeProber` = IsAlive (reaper.New takes it) -- `internal/session_manager/manager.go` `runtimeController` = Create + Destroy - (Deps.Runtime is type `runtimeController`) -- `internal/review/launcher.go` `reviewerRuntime` = Create + IsAlive + SendMessage - (NewLauncher takes it) -- `internal/daemon/lifecycle_wiring.go` `startLifecycle` takes `ports.Runtime` - -The ONLY place pinned to the concrete type is -`startSession(cfg, runtime *zellij.Runtime, ...)` at -`internal/daemon/lifecycle_wiring.go:65`, called from `daemon.go:122`. - -Both `zellij.Runtime` and `tmux.Runtime` implement the SAME superset of methods: -Create, Destroy, IsAlive, SendMessage, GetOutput, AttachCommand. - -## What to build - -### 1. New package `internal/adapters/runtime/runtimeselect` -Define the union interface that the daemon wires and that both adapters satisfy: -```go -package runtimeselect - -type Runtime interface { - ports.Runtime // Create, Destroy, IsAlive - SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error - GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) - AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) -} -``` -Add a constructor that picks the backend by OS: -```go -// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows -// (the Windows ConPTY adapter lands in a later phase). log is used only for the -// Windows zellij socket-dir warning; nil is allowed. -func New(log *slog.Logger) Runtime -``` -- Non-Windows (`runtime.GOOS != "windows"`): return `tmux.New(tmux.Options{})`. -- Windows: replicate today's daemon.go behavior exactly — compute - `zellij.DefaultSocketDir()`, `os.MkdirAll(dir, 0o700)` and on error - `log.Warn(... "could not create zellij socket dir; spawns may fail" ...)` (guard - on nil log), then return `zellij.New(zellij.Options{SocketDir: dir})`. -- Add compile-time assertions: `var _ Runtime = (*tmux.Runtime)(nil)` and - `var _ Runtime = (*zellij.Runtime)(nil)`. - -Keep it minimal (ponytail): no Options struct, no registry, no env knobs the daemon -will not set. A plain `runtime.GOOS` switch is the right altitude (mirrors -agent-orchestrator's `getDefaultRuntime()`). - -### 2. Rewire `internal/daemon/daemon.go` -Replace the zellij-specific block (the `zellijSocketDir := zellij.DefaultSocketDir()` -+ MkdirAll + warn + `runtimeAdapter := zellij.New(...)`, currently lines ~89-99) -with a single `runtimeAdapter := runtimeselect.New(log)`. Update the nearby comment -so it no longer says "the Zellij runtime supplies..." — make it runtime-neutral -(e.g. "the selected runtime (tmux on macOS/Linux, zellij on Windows) supplies the -PTY-attach command and liveness"). Remove the now-unused `zellij` import from -daemon.go IF nothing else there uses it (check; `os` may also become unused — only -remove imports that are truly unused). `terminal.NewManager(runtimeAdapter, ...)`, -`newSessionMessenger(store, runtimeAdapter, ...)`, `startLifecycle(..., runtimeAdapter, ...)`, -and `startSession(cfg, runtimeAdapter, ...)` calls stay as-is (they take interfaces -or the union). - -### 3. Change `startSession` signature in `internal/daemon/lifecycle_wiring.go` -Change the param `runtime *zellij.Runtime` to `runtime runtimeselect.Runtime`. -Update the doc comment ("over the real zellij runtime" -> runtime-neutral). The body -is unchanged: it passes `runtime` into `sessionmanager.Deps{Runtime: runtime}` (the -union satisfies `runtimeController`) and `reviewcore.NewLauncher(reviewers, runtime)` -(the union satisfies `reviewerRuntime`). Remove the now-unused `zellij` import from -lifecycle_wiring.go if it becomes unused. - -### 4. Runtime-aware doctor check `internal/cli/doctor.go` -Today (~lines 294-308) it runs `zellij --version` and validates against -`zellij.CheckVersionOutput`/`zellij.RequiredVersion`. Make the tool check match the -selected runtime: -- Non-Windows: check for **tmux**. Resolve `exec.LookPath("tmux")`; run `tmux -V`; - report a pass with the version string (e.g. "tmux (version 3.6b)"). tmux has no - hard minimum version for our usage, so presence + version is enough — do NOT - invent a strict semver gate. If tmux is missing, report the existing failure - level the function uses for a missing tool, with name "tmux". -- Windows: keep the existing zellij version check unchanged. -Use the same `doctorCheck{...}` shape, `doctorSectionTools` section, and pass/fail -levels already in the file. Read the surrounding function to match its structure and -the exact way it shells out (it likely has a helper for running the tool). - -### 5. Runtime-aware attach hint `internal/cli/spawn.go` -Today (~lines 101-106) it prints `zellij attach ` plus a -ZELLIJ_SOCKET_DIR note. Make it runtime-aware: -- Non-Windows: print `tmux attach -t ` where `` is the tmux session - name for the session id. For this you need an EXPORTED sanitizer in the tmux - package: add `func SessionName(id string) string` to the tmux package (it - already sanitizes internally for Create — expose that same function, mirroring - `zellij.SessionName`). No socket-dir note is needed for tmux. -- Windows: keep the existing `zellij attach` hint (with the socket-dir note). - -### 6. Update `internal/daemon/wiring_test.go` -It currently calls `zellij.New(zellij.Options{})`, `startSession(cfg, runtime, ...)`, -`startLifecycle(ctx, store, zellij.New(...), ...)`, and `zellij.DefaultSocketDir()`. -After the signature change, `startSession` takes `runtimeselect.Runtime`. Update the -test to pass a value that satisfies it. Simplest and most faithful: construct the -real selected runtime via `runtimeselect.New(nil)` (on this Darwin box that yields -the tmux adapter, and tmux is installed), OR pass `tmux.New(tmux.Options{})` -directly. Keep the test's intent. If a sub-test asserts specifically on -`zellij.DefaultSocketDir()` semantics, keep that sub-test pointed at zellij directly -(it is testing the zellij helper, which still exists) — do not delete coverage; just -make it compile and pass. Read the whole test file and adjust only what the signature -change forces. - -## Definition of done (runnable checks — run from `backend/`) -- `go build ./...` succeeds. -- `GOOS=windows go build ./...` succeeds (Windows still compiles on the zellij path). -- `go test ./...` passes (the full backend suite). If any pre-existing test is - flaky/unrelated-failing, note it in the report; do not paper over a failure your - change caused. -- `go vet ./internal/daemon/... ./internal/cli/... ./internal/adapters/runtime/...` - is clean. - -## Hard rules / constraints -- Never use em dashes ("—") anywhere in code, comments, or commit messages. Use - periods, commas, semicolons, or parentheses. -- Do NOT delete the zellij package or change its behavior (Windows depends on it). -- Do NOT change the narrow consumer interfaces or the terminal layer. -- No new Go dependencies. go.mod unchanged. -- Keep changes minimal and on-pattern (ponytail). Mark any deliberate shortcut with - a `ponytail:` comment. -- Commit on the current branch with a clear message ending in the - `Co-Authored-By: Claude Opus 4.8 ` trailer. diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md deleted file mode 100644 index efd38b62..00000000 --- a/.superpowers/sdd/task-2-report.md +++ /dev/null @@ -1,158 +0,0 @@ -# Task 2 Report: StashUncommitted + ApplyPreserved - -## Status: DONE - -## What was implemented - -Two methods on the `gitworktree.Workspace` adapter, plus corresponding interface and test-double updates. - -### StashUncommitted - -Location: `backend/internal/adapters/workspace/gitworktree/workspace.go` - -Algorithm: -1. `isDirty` early-exit for clean worktrees (returns `""`, nil). -2. Counts ignored-path skips via `git status --ignored --porcelain` and logs via `slog.InfoContext`. -3. Reserves a unique path via `os.CreateTemp` then immediately removes the 0-byte file so git sees an absent path (not a corrupt index). Deferred `os.Remove` cleans up after git writes it. -4. `GIT_INDEX_FILE= git -C add -A` stages tracked edits and new non-ignored files, skipping .gitignore entries (no -f/--force). -5. `GIT_INDEX_FILE= git -C write-tree` yields a tree SHA. -6. Compares tree SHA against `HEAD^{tree}`. If equal, only ignored files differ and nothing to preserve: returns `""`, nil. -7. `git -C commit-tree [-p ] -m "ao preserved "` (omits -p on unborn HEAD). -8. `git -C update-ref refs/ao/preserved/ ` and returns the ref name. - -### ApplyPreserved - -1. Resolves the ref to a commit SHA via `rev-parse --verify`. -2. Runs `git checkout -- .` which restores all files from the preserve tree onto the working tree without switching HEAD. Correctly reproduces tracked-file edits AND new untracked files that were captured by StashUncommitted. -3. On conflict (non-zero exit with "conflict" in output): returns `fmt.Errorf("%w: %w", ErrPreservedConflict, applyErr)`. Ref is NOT deleted. -4. On clean success: `git update-ref -d refs/ao/preserved/`. Warn-logs if ref deletion fails (does not fail the call). - -### Supporting additions - -- `commands.go`: added `addAllTempIndexArgs`, `writeTreeArgs`, `commitTreeArgs`, `updateRefArgs`, `deleteRefArgs`, `revParseHeadArgs`, `checkoutTreeArgs`, `ignoredCountArgs`. -- `ports/outbound.go`: added `StashUncommitted` and `ApplyPreserved` to the `Workspace` interface. -- `ErrPreservedConflict` exported sentinel in `workspace.go`. -- Stub implementations added to `integration/lifecycle_sqlite_test.go` and `session_manager/manager_test.go`. - -## TDD Evidence - -### RED (tests written first, before any implementation) - -``` -$ go test ./internal/adapters/workspace/gitworktree/... -gitworktree [build failed] - workspace_preserve_test.go:64:17: ws.StashUncommitted undefined (type *Workspace...) - workspace_preserve_test.go:93:15: ws.ApplyPreserved undefined (type *Workspace...) - workspace_preserve_test.go:142:17: ws.StashUncommitted undefined (type *Workspace...) -``` - -Tests failed because the methods did not exist yet. Confirmed they tested the right thing, not pre-existing behavior. - -### GREEN (after implementation) - -``` -$ go test ./internal/adapters/workspace/gitworktree/... -v -Go test: 39 passed in 1 packages -``` - -All 39 tests pass, including: -- `TestWorkspaceIntegrationStashApplyRoundTrip`: full round-trip with tracked edit + new non-ignored file + .gitignore-d file. Asserts ref is non-empty, README edit reappears, agent-work.go reappears, secret.txt does NOT reappear, and ref is deleted after successful apply. -- `TestWorkspaceIntegrationStashCleanWorktree`: clean worktree returns empty ref. -- All 37 pre-existing tests unchanged. - -### Build and vet - -``` -$ go build ./... # Build: Success -$ go vet ./... # Vet: No issues found -``` - -## Files Changed - -- `backend/internal/adapters/workspace/gitworktree/workspace.go` (added StashUncommitted, ApplyPreserved, runCheckoutTree, countIgnoredPaths, ErrPreservedConflict, log/slog import) -- `backend/internal/adapters/workspace/gitworktree/commands.go` (added 8 new arg builders) -- `backend/internal/adapters/workspace/gitworktree/workspace_preserve_test.go` (new, 2 integration tests) -- `backend/internal/ports/outbound.go` (added 2 methods to Workspace interface) -- `backend/internal/integration/lifecycle_sqlite_test.go` (no-op stubs for new interface methods) -- `backend/internal/session_manager/manager_test.go` (no-op stubs for new interface methods) - -## Commit - -`cbe6f21 feat(workspace): add StashUncommitted and ApplyPreserved for session lifecycle` - -## Self-review checklist - -- [x] Preserve ref is exactly `refs/ao/preserved/` -- [x] `.gitignore` respected: no `-f`/`--force` on `git add`, ignored count logged -- [x] Temp index file does NOT pre-exist when git writes it (0-byte file removed before git-add) -- [x] Working tree and global stash stack are never mutated during StashUncommitted -- [x] Clean worktree returns `""` and no error (two guards: isDirty + tree-SHA comparison) -- [x] Unborn HEAD handled: headSHA stays empty, commitTreeArgs omits `-p` -- [x] Ref deleted only after a confirmed clean apply -- [x] Ref kept on conflict; ErrPreservedConflict returned wrapped -- [x] No em dashes or en dashes anywhere in code, comments, or messages -- [x] App state stays under ~/.ao (temp index in os.TempDir, preserve refs in project .git) -- [x] All 39 tests pass, go vet clean, go build clean - -## Concerns - -One mild concern: `git checkout -- .` also updates the staging area (index) in the re-added worktree, not just the working tree. This means after ApplyPreserved the restored files appear staged. For the session-lifecycle use case (agent resumes work) this is harmless and arguably desirable (the agent can commit immediately). If a future caller wants the files to appear as unstaged modifications, a `git reset HEAD` afterward would be needed. Current behavior matches the task requirements. - ---- - -## Review Fix: Replace checkout with cherry-pick for correct conflict detection - -### Status: DONE (review findings resolved) - -### Problem found in review - -`ApplyPreserved` used `git checkout -- .`, which is a path-checkout (not a merge). Path-checkout unconditionally overwrites the working tree and always exits 0 for content divergence. This made the `if applyErr != nil` conflict-handling block unreachable dead code and left the spec requirement unmet: the `ErrPreservedConflict` sentinel could never be returned. - -### New mechanism: cherry-pick --no-commit - -Replaced with `git cherry-pick --no-commit `. This computes the diff between the preserve commit and its parent (HEAD at save time) and performs a true three-way merge onto the current working tree. On conflict it leaves textual conflict markers (`<<<<<<<` etc.) in the affected files and exits non-zero WITHOUT committing or moving HEAD. Exit code alone drives conflict detection (locale-independent). New untracked files in the preserve tree come through as additions. - -The `--no-commit` (`-n`) flag leaves no sequencer state that would require a follow-up `cherry-pick --abort`. - -### Code changes - -- `commands.go`: removed `checkoutTreeArgs` (path-checkout), added `cherryPickNoCommitArgs` (three-way merge). -- `workspace.go`: replaced `runCheckoutTree` with `runCherryPickNoCommit`; replaced string-scan conflict detection (`strings.Contains(out, "conflict")`) with exit-code detection (any non-zero exit from the merge step returns `ErrPreservedConflict` immediately). - -### RED evidence - -Under the OLD mechanism (`git checkout -- .`), the new conflict test (`TestWorkspaceIntegrationApplyPreservedConflict`) would have failed because: - -1. `git checkout -- .` exits 0 even when file content diverges (path-checkout never writes conflict markers and never signals a conflict). -2. `applyErr` would always be nil, so `ErrPreservedConflict` would never be returned. -3. The assertion `errors.Is(applyErr, ErrPreservedConflict)` would fail with "ApplyPreserved returned nil, want ErrPreservedConflict". - -### GREEN evidence - -``` -$ /opt/homebrew/bin/go test -v ./internal/adapters/workspace/gitworktree/ \ - -run "TestWorkspaceIntegrationStashApplyRoundTrip|TestWorkspaceIntegrationApplyPreservedConflict" \ - -count=1 - -=== RUN TestWorkspaceIntegrationStashApplyRoundTrip -2026/06/24 05:39:57 INFO gitworktree: StashUncommitted skipping ignored paths session=sess-preserve skipped_count=1 ---- PASS: TestWorkspaceIntegrationStashApplyRoundTrip (0.47s) -=== RUN TestWorkspaceIntegrationApplyPreservedConflict -2026/06/24 05:39:57 INFO gitworktree: StashUncommitted skipping ignored paths session=sess-conflict skipped_count=0 ---- PASS: TestWorkspaceIntegrationApplyPreservedConflict (0.46s) -PASS -ok github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree 1.072s -``` - -Full package: 40 tests pass (was 39 before this fix added the conflict test). - -``` -$ go build ./... # Build: Success -$ go vet ./internal/adapters/workspace/gitworktree/... # Vet: No issues found -``` - -### Files changed in this review fix - -- `backend/internal/adapters/workspace/gitworktree/workspace.go` (ApplyPreserved + runCherryPickNoCommit replacing runCheckoutTree) -- `backend/internal/adapters/workspace/gitworktree/commands.go` (cherryPickNoCommitArgs replacing checkoutTreeArgs) -- `backend/internal/adapters/workspace/gitworktree/workspace_preserve_test.go` (added TestWorkspaceIntegrationApplyPreservedConflict) diff --git a/.superpowers/sdd/task-2-review-package.txt b/.superpowers/sdd/task-2-review-package.txt deleted file mode 100644 index b8d0465e..00000000 --- a/.superpowers/sdd/task-2-review-package.txt +++ /dev/null @@ -1,1069 +0,0 @@ -=== COMMITS === -b459abf feat(runtime): wire tmux on Darwin/Linux via runtimeselect, keep zell... - -=== STAT === -.superpowers/sdd/task-2-report.md | 95 ++++++++++++++++++++++ - .../runtime/runtimeselect/runtimeselect.go | 46 +++++++++++ - backend/internal/cli/doctor.go | 31 ++++++- - backend/internal/cli/doctor_test.go | 38 +++++---- - backend/internal/cli/spawn.go | 21 +++-- - backend/internal/daemon/daemon.go | 20 ++--- - backend/internal/daemon/lifecycle_wiring.go | 6 +- - backend/internal/daemon/wiring_test.go | 7 +- - 8 files changed, 216 insertions(+), 48 deletions(-) - -=== DIFF === -.superpowers/sdd/task-2-report.md | 95 ++++++++++++++++++++++ - .../runtime/runtimeselect/runtimeselect.go | 46 +++++++++++ - backend/internal/cli/doctor.go | 31 ++++++- - backend/internal/cli/doctor_test.go | 38 +++++---- - backend/internal/cli/spawn.go | 21 +++-- - backend/internal/daemon/daemon.go | 20 ++--- - backend/internal/daemon/lifecycle_wiring.go | 6 +- - backend/internal/daemon/wiring_test.go | 7 +- - 8 files changed, 216 insertions(+), 48 deletions(-) - -diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md -new file mode 100644 -index 0000000..2a84176 ---- /dev/null -+++ b/.superpowers/sdd/task-2-report.md -@@ -0,0 +1,95 @@ -+# Task 2 Report: runtime selection + daemon wiring + doctor/spawn hints -+ -+## Status: DONE -+ -+## Files Changed -+ -+### New file -+- `backend/internal/adapters/runtime/runtimeselect/runtimeselect.go` -+ - Defines the `Runtime` union interface (embeds `ports.Runtime`; adds `SendMessage`, `GetOutput`, `AttachCommand`). -+ - `New(log *slog.Logger) Runtime` returns `tmux.New(tmux.Options{})` on non-Windows, and replicates the old daemon zellij socket-dir setup on Windows. -+ - Compile-time assertions: `var _ Runtime = (*tmux.Runtime)(nil)` and `var _ Runtime = (*zellij.Runtime)(nil)`. -+ -+### Modified files -+ -+**`backend/internal/daemon/daemon.go`** -+- Swapped `zellij` import for `runtimeselect`. -+- Replaced the 10-line zellij socket-dir block with `runtimeAdapter := runtimeselect.New(log)`. -+- Updated the terminal-streaming comment to be runtime-neutral. -+- `os` import retained (still used by `newLogger()` for `os.Stderr`). -+ -+**`backend/internal/daemon/lifecycle_wiring.go`** -+- Swapped `zellij` import for `runtimeselect`. -+- Changed `startSession` param from `runtime *zellij.Runtime` to `runtime runtimeselect.Runtime`. -+- Updated doc comment from "over the real zellij runtime" to "over the selected runtime". -+ -+**`backend/internal/cli/doctor.go`** -+- Added `"runtime"` stdlib import. -+- Renamed call site from `c.checkZellij(ctx)` to `c.checkTerminalRuntime(ctx)`. -+- Added `checkTerminalRuntime` dispatcher: calls `checkTmux` on non-Windows, `checkZellij` on Windows. -+- Added `checkTmux`: resolves `tmux` via `LookPath`, runs `tmux -V`, reports PASS with version string; WARN for missing (matching the zellij missing-level so end-to-end `ok: true` tests remain green), FAIL if found but version command errors. -+- Kept `checkZellij` intact (Windows still uses it). -+ -+**`backend/internal/cli/spawn.go`** -+- Added `"runtime"` stdlib import and `tmux` adapter import. -+- Replaced unconditional `zellij attach` hint with a runtime-aware block: -+ - Non-Windows: `tmux attach -t ` using `tmux.SessionName(res.Session.ID)`. -+ - Windows: existing `ZELLIJ_SOCKET_DIR=... zellij attach ` hint (unchanged). -+ -+**`backend/internal/daemon/wiring_test.go`** -+- Added `runtimeselect` import. -+- `TestWiring_StartSessionBuildsSessionService`: replaced `zellij.New(zellij.Options{})` with `runtimeselect.New(nil)` so the test compiles and passes after the `startSession` signature change. -+- `TestWiring_StartLifecycleThreadsMessengerIntoLCM` and `TestDaemonZellijSocketDir_LeavesBudgetForSessionNames` still use `zellij` directly; those sub-tests are testing zellij semantics that still exist and were not changed. -+ -+**`backend/internal/cli/doctor_test.go`** -+- Renamed and rewrote the three zellij-specific tool tests to cover `checkTmux` (on Darwin): -+ - `TestDoctorChecksZellijVersion` -> `TestDoctorChecksTmuxVersion` -+ - `TestDoctorFailsUnsupportedZellijVersion` -> `TestDoctorChecksTmuxVersionFailsOnError` -+ - `TestDoctorWarnsWhenZellijMissing` -> `TestDoctorWarnsWhenTmuxMissing` -+ -+## Union Interface -+ -+```go -+type Runtime interface { -+ ports.Runtime // Create, Destroy, IsAlive -+ SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error -+ GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) -+ AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) -+} -+``` -+ -+## SessionName export -+ -+`tmux.SessionName(id string) string` was already exported in Task 1 (line 267 of `tmux.go`). No change needed. -+ -+## Handling of wiring_test.go -+ -+The only test that called `startSession` with a concrete `*zellij.Runtime` was `TestWiring_StartSessionBuildsSessionService`. It now passes `runtimeselect.New(nil)` (which returns the tmux adapter on Darwin). Tests that still use `zellij.New` directly (`startLifecycle` call and `DefaultSocketDir` assertion) were left untouched; they test zellij semantics and compile fine because the zellij import remains. -+ -+## Verification Outputs -+ -+All run from `backend/`. -+ -+### `go build ./...` -+``` -+Go build: Success -+``` -+ -+### `GOOS=windows go build ./...` -+``` -+Go build: Success -+``` -+ -+### `go test ./...` -+``` -+Go test: 1585 passed in 75 packages -+``` -+ -+### `go vet ./internal/daemon/... ./internal/cli/... ./internal/adapters/runtime/...` -+``` -+Go vet: No issues found -+``` -+ -+## Concerns -+ -+None. All checks green, no pre-existing failures, no new dependencies, go.mod unchanged. -diff --git a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go -new file mode 100644 -index 0000000..e6ca4a0 ---- /dev/null -+++ b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go -@@ -0,0 +1,46 @@ -+// Package runtimeselect picks the correct runtime backend by platform: -+// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). -+package runtimeselect -+ -+import ( -+ "context" -+ "log/slog" -+ "os" -+ "runtime" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" -+) -+ -+// Runtime is the union interface that both tmux and zellij satisfy. -+// It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods -+// the daemon wires directly. -+type Runtime interface { -+ ports.Runtime // Create, Destroy, IsAlive -+ SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error -+ GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) -+ AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) -+} -+ -+// Compile-time assertions: both adapters must implement the union interface. -+var _ Runtime = (*tmux.Runtime)(nil) -+var _ Runtime = (*zellij.Runtime)(nil) -+ -+// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. -+// log is used only for the Windows zellij socket-dir warning; nil is safe. -+func New(log *slog.Logger) Runtime { -+ if runtime.GOOS != "windows" { -+ return tmux.New(tmux.Options{}) -+ } -+ // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. -+ dir := zellij.DefaultSocketDir() -+ if dir != "" { -+ if err := os.MkdirAll(dir, 0o700); err != nil { -+ if log != nil { -+ log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) -+ } -+ } -+ } -+ return zellij.New(zellij.Options{SocketDir: dir}) -+} -diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go -index 60b0e29..c6def22 100644 ---- a/backend/internal/cli/doctor.go -+++ b/backend/internal/cli/doctor.go -@@ -10,20 +10,22 @@ import ( - "net/http" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - "github.com/spf13/cobra" - -+ "runtime" -+ - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - ) - - type doctorLevel string - - const ( - doctorPass doctorLevel = "PASS" - doctorWarn doctorLevel = "WARN" -@@ -161,21 +163,21 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { - msg = fmt.Sprintf("%s pid=%d port=%d", msg, st.PID, st.Port) - } - if st.Error != "" { - msg += " (" + st.Error + ")" - } - checks = append(checks, doctorCheck{Level: level, Section: doctorSectionCore, Name: "daemon", Message: msg}) - } - - checks = append(checks, - c.checkGit(ctx), -- c.checkZellij(ctx), -+ c.checkTerminalRuntime(ctx), - c.checkAOBinary(), - ) - for _, harness := range doctorHarnesses { - checks = append(checks, c.checkHarness(ctx, harness)) - } - checks = append(checks, c.checkCodexLaunchFlags(ctx), c.checkGitHubToken(ctx)) - return checks - } - - // checkStore inspects the SQLite store WITHOUT opening or migrating it. The -@@ -283,20 +285,47 @@ func (c *commandContext) checkGit(ctx context.Context) doctorCheck { - cmp, err := compareDottedVersion(version, minGitVersion) - if err != nil { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version unknown: %s)", path, firstOutputLine(out))} - } - if cmp < 0 { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; AO expects >= %s for worktrees)", path, version, minGitVersion)} - } - return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; supports worktrees)", path, version)} - } - -+// checkTerminalRuntime checks for the runtime multiplexer used on this platform: -+// tmux on Darwin/Linux, zellij on Windows. -+func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { -+ if runtime.GOOS == "windows" { -+ return c.checkZellij(ctx) -+ } -+ return c.checkTmux(ctx) -+} -+ -+func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { -+ path, err := c.deps.LookPath("tmux") -+ if err != nil || path == "" { -+ return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"} -+ } -+ reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) -+ defer cancel() -+ out, err := c.deps.CommandOutput(reqCtx, path, "-V") -+ if err != nil { -+ return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s: %v", path, err)} -+ } -+ version := firstOutputLine(out) -+ if version == "" { -+ version = "version unknown" -+ } -+ return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s)", path, version)} -+} -+ - func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { - path, err := c.deps.LookPath("zellij") - if err != nil || path == "" { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "zellij", Message: "not found in PATH"} - } - reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) - defer cancel() - out, err := c.deps.CommandOutput(reqCtx, path, "--version") - if err != nil { - return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} -diff --git a/backend/internal/cli/doctor_test.go b/backend/internal/cli/doctor_test.go -index c1bf169..2e4acf0 100644 ---- a/backend/internal/cli/doctor_test.go -+++ b/backend/internal/cli/doctor_test.go -@@ -45,67 +45,69 @@ func TestDoctorWarnsOnUnsupportedGitVersion(t *testing.T) { - func TestDoctorFailsWhenGitMissing(t *testing.T) { - setConfigEnv(t) - c := doctorContext(t, map[string]string{}, nil) - - check := findDoctorCheck(t, c.runDoctor(context.Background()), "git") - if check.Level != doctorFail { - t.Fatalf("git check = %+v, want FAIL", check) - } - } - --func TestDoctorChecksZellijVersion(t *testing.T) { -+func TestDoctorChecksTmuxVersion(t *testing.T) { - setConfigEnv(t) -- c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, args ...string) ([]byte, error) { -+ c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, args ...string) ([]byte, error) { - switch name { - case "/bin/git": - return []byte("git version 2.43.0\n"), nil -- case "/bin/zellij": -- if len(args) != 1 || args[0] != "--version" { -- t.Fatalf("unexpected zellij command: %s %v", name, args) -+ case "/bin/tmux": -+ if len(args) != 1 || args[0] != "-V" { -+ t.Fatalf("unexpected tmux command: %s %v", name, args) - } -- return []byte("zellij 0.44.3\n"), nil -+ return []byte("tmux 3.3a\n"), nil - default: - t.Fatalf("unexpected command: %s %v", name, args) - return nil, nil - } - }) - -- check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") -- if check.Level != doctorPass || !strings.Contains(check.Message, "0.44.3") { -- t.Fatalf("zellij check = %+v, want PASS with version", check) -+ check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") -+ if check.Level != doctorPass || !strings.Contains(check.Message, "3.3a") { -+ t.Fatalf("tmux check = %+v, want PASS with version", check) - } - } - --func TestDoctorFailsUnsupportedZellijVersion(t *testing.T) { -+// TestDoctorChecksTmuxVersionFailsOnError covers the case where tmux is found -+// but the version command fails. -+func TestDoctorChecksTmuxVersionFailsOnError(t *testing.T) { - setConfigEnv(t) -- c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { -+ c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { - if name == "/bin/git" { - return []byte("git version 2.43.0\n"), nil - } -- return []byte("zellij 0.44.2\n"), nil -+ return nil, errors.New("exec: tmux: not found") - }) - -- check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") -- if check.Level != doctorFail || !strings.Contains(check.Message, "require >= 0.44.3") { -- t.Fatalf("zellij check = %+v, want FAIL with minimum version", check) -+ check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") -+ if check.Level != doctorFail { -+ t.Fatalf("tmux check = %+v, want FAIL on version error", check) - } - } - --func TestDoctorWarnsWhenZellijMissing(t *testing.T) { -+func TestDoctorWarnsWhenTmuxMissing(t *testing.T) { - setConfigEnv(t) - c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { - return []byte("git version 2.43.0\n"), nil - }) - -- check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") -+ check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") - if check.Level != doctorWarn { -- t.Fatalf("zellij check = %+v, want WARN", check) -+ t.Fatalf("tmux check = %+v, want WARN", check) - } - } - - func TestDoctorChecksHarnessVersions(t *testing.T) { - setConfigEnv(t) - cmdPath := map[string]string{ - "git": "/bin/git", - "claude": "/bin/claude", - "codex": "/bin/codex", - } -diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go -index b5cc717..ae7bac4 100644 ---- a/backend/internal/cli/spawn.go -+++ b/backend/internal/cli/spawn.go -@@ -1,20 +1,22 @@ - package cli - - import ( - "context" - "fmt" - "net/url" -+ "runtime" - - "github.com/spf13/cobra" - "github.com/spf13/pflag" - -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - ) - - type spawnOptions struct { - project string - harness string - branch string - prompt string - issue string - claimPR string -@@ -90,28 +92,31 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { - } - } - out := cmd.OutOrStdout() - claimLabel := "" - if claimed != "" { - claimLabel = fmt.Sprintf(" (claimed %s)", claimed) - } - if _, err := fmt.Fprintf(out, "spawned session %s (%s)%s\n", res.Session.ID, res.Session.Status, claimLabel); err != nil { - return err - } -- // The daemon runs zellij under a short, non-default socket dir (see -- // zellij.DefaultSocketDir), so a plain `zellij attach` wouldn't find -- // the session — prefix the env so the hint is copy-pasteable. Use the -- // sanitised name zellij actually registers (zellij.SessionName): a long -- // session id maps to a different name than the raw id. -- attach := fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) -- if dir := zellij.DefaultSocketDir(); dir != "" { -- attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) -+ // Print a copy-pasteable attach hint for the selected runtime. -+ // On Darwin/Linux: tmux attach-session using the sanitised session name. -+ // On Windows: zellij attach with the short socket dir prefix. -+ var attach string -+ if runtime.GOOS != "windows" { -+ attach = fmt.Sprintf("tmux attach -t %s", tmux.SessionName(res.Session.ID)) -+ } else { -+ attach = fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) -+ if dir := zellij.DefaultSocketDir(); dir != "" { -+ attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) -+ } - } - _, err := fmt.Fprintf(out, "attach with: %s\n", attach) - return err - }, - } - f := cmd.Flags() - // --agent is an alias for --harness so the more intuitive `ao spawn --agent - // droid` works identically; both resolve to the same harness flag. - f.SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName { - if name == "agent" { -diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go -index 59791c8..2bdab64 100644 ---- a/backend/internal/daemon/daemon.go -+++ b/backend/internal/daemon/daemon.go -@@ -6,21 +6,21 @@ package daemon - import ( - "context" - "fmt" - "log/slog" - "net/http" - "os" - "os/signal" - "syscall" - "time" - -- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/httpd" - "github.com/aoagents/agent-orchestrator/backend/internal/notify" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/preview" - "github.com/aoagents/agent-orchestrator/backend/internal/runfile" - notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" - projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" - "github.com/aoagents/agent-orchestrator/backend/internal/terminal" -@@ -75,35 +75,25 @@ func Run() error { - // signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the - // graceful shutdown inside Server.Run and stops the background goroutines. - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - cdcPipe, err := startCDC(ctx, store, log) - if err != nil { - return err - } - -- // Terminal streaming: the Zellij runtime supplies the PTY-attach command and -- // liveness; the CDC broadcaster feeds the session-state channel. The manager -+ // Terminal streaming: the selected runtime (tmux on macOS/Linux, zellij on Windows) supplies the -+ // PTY-attach command and liveness; the CDC broadcaster feeds the session-state channel. The manager - // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow -- // through the CDC change_log — only session-state events do. -- // zellij's default socket dir is too long on macOS for long session ids -- // (see zellij.DefaultSocketDir); use a short, stable one and ensure it exists. -- zellijSocketDir := zellij.DefaultSocketDir() -- if zellijSocketDir != "" { -- if err := os.MkdirAll(zellijSocketDir, 0o700); err != nil { -- // Don't abort startup, but surface it: every spawn's zellij session -- // would otherwise fail later with an opaque socket-bind error. -- log.Warn("could not create zellij socket dir; spawns may fail", "dir", zellijSocketDir, "error", err) -- } -- } -- runtimeAdapter := zellij.New(zellij.Options{SocketDir: zellijSocketDir}) -+ // through the CDC change_log -- only session-state events do. -+ runtimeAdapter := runtimeselect.New(log) - termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) - defer termMgr.Close() - - // The agent messenger sends validated user input to the session's live - // zellij pane. Keep this path small until durable inbox semantics are needed. - // Built before the Lifecycle Manager so the LCM can use it for SCM-driven - // agent nudges (CI failure, review feedback, merge conflict). - messenger := newSessionMessenger(store, runtimeAdapter, log) - notificationHub := notify.NewHub() - notifier := notificationsvc.New(notificationsvc.Deps{Store: store}) -diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go -index 3bf5ff7..5d21160 100644 ---- a/backend/internal/daemon/lifecycle_wiring.go -+++ b/backend/internal/daemon/lifecycle_wiring.go -@@ -3,21 +3,21 @@ package daemon - import ( - "context" - "fmt" - "log/slog" - "path/filepath" - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitydispatch" - agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/reviewer" -- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" - "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" - reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" - sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" - sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" -@@ -52,24 +52,24 @@ func startLifecycle(ctx context.Context, store *sqlite.Store, runtime ports.Runt - // Stop waits for the reaper goroutine to exit. The caller must cancel the ctx - // passed to startLifecycle before calling Stop. - func (l *lifecycleStack) Stop() { - <-l.reaperDone - if l.scmDone != nil { - <-l.scmDone - } - } - - // startSession builds the controller-facing session service: a session manager --// over the real zellij runtime, a per-session gitworktree workspace, the shared -+// over the selected runtime, a per-session gitworktree workspace, the shared - // store + LCM, the per-session agent resolver, and the agent messenger. The - // returned service is mounted at httpd APIDeps.Sessions. --func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { -+func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { - defaultAgent := cfg.Agent - if defaultAgent == "" { - defaultAgent = config.DefaultAgent - } - agents, err := buildAgentResolver(defaultAgent, log) - if err != nil { - return nil, nil, err - } - ws, err := gitworktree.New(gitworktree.Options{ - // Per-session worktrees live under the data dir, so a single AO_DATA_DIR -diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go -index a3acd55..21bd248 100644 ---- a/backend/internal/daemon/wiring_test.go -+++ b/backend/internal/daemon/wiring_test.go -@@ -3,20 +3,21 @@ package daemon - import ( - "context" - "errors" - "io" - "log/slog" - "sync" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters" -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - telemetryadapter "github.com/aoagents/agent-orchestrator/backend/internal/adapters/telemetry" - "github.com/aoagents/agent-orchestrator/backend/internal/cdc" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" - ) -@@ -142,23 +143,23 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { - store, err := sqlite.Open(t.TempDir()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = store.Close() }) - - log := slog.New(slog.NewTextHandler(io.Discard, nil)) - lcm := lifecycle.New(store, nil) - cfg := config.Config{DataDir: t.TempDir()} - -- runtime := zellij.New(zellij.Options{}) -- messenger := newSessionMessenger(store, runtime, log) -- svc, reviewSvc, err := startSession(cfg, runtime, store, lcm, messenger, telemetryadapter.NoopSink{}, log) -+ rt := runtimeselect.New(nil) -+ messenger := newSessionMessenger(store, rt, log) -+ svc, reviewSvc, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) - if err != nil { - t.Fatalf("startSession: %v", err) - } - if svc == nil { - t.Fatal("startSession returned nil session service") - } - if reviewSvc == nil { - t.Fatal("startSession returned nil review service") - } - } - ---- Changes --- - -.superpowers/sdd/task-2-report.md - @@ -0,0 +1,95 @@ - +# Task 2 Report: runtime selection + daemon wiring + doctor/spawn hints - + - +## Status: DONE - + - +## Files Changed - + - +### New file - +- `backend/internal/adapters/runtime/runtimeselect/runtimeselect.go` - + - Defines the `Runtime` union interface (embeds `ports.Runtime`; adds `SendMessage`, `GetOutput`, `AttachCommand`). - + - `New(log *slog.Logger) Runtime` returns `tmux.New(tmux.Options{})` on non-Windows, and replicates the old daemon zellij socket-dir setup on Windows. - + - Compile-time assertions: `var _ Runtime = (*tmux.Runtime)(nil)` and `var _ Runtime = (*zellij.Runtime)(nil)`. - + - +### Modified files - + - +**`backend/internal/daemon/daemon.go`** - +- Swapped `zellij` import for `runtimeselect`. - +- Replaced the 10-line zellij socket-dir block with `runtimeAdapter := runtimeselect.New(log)`. - +- Updated the terminal-streaming comment to be runtime-neutral. - +- `os` import retained (still used by `newLogger()` for `os.Stderr`). - + - +**`backend/internal/daemon/lifecycle_wiring.go`** - +- Swapped `zellij` import for `runtimeselect`. - +- Changed `startSession` param from `runtime *zellij.Runtime` to `runtime runtimeselect.Runtime`. - +- Updated doc comment from "over the real zellij runtime" to "over the selected runtime". - + - +**`backend/internal/cli/doctor.go`** - +- Added `"runtime"` stdlib import. - +- Renamed call site from `c.checkZellij(ctx)` to `c.checkTerminalRuntime(ctx)`. - +- Added `checkTerminalRuntime` dispatcher: calls `checkTmux` on non-Windows, `checkZellij` on Windows. - +- Added `checkTmux`: resolves `tmux` via `LookPath`, runs `tmux -V`, reports PASS with version string; WARN for missing (matching the zellij missing-level so end-to-end `ok: true` tests remain green), FAIL if found but version command errors. - +- Kept `checkZellij` intact (Windows still uses it). - + - +**`backend/internal/cli/spawn.go`** - +- Added `"runtime"` stdlib import and `tmux` adapter import. - +- Replaced unconditional `zellij attach` hint with a runtime-aware block: - + - Non-Windows: `tmux attach -t ` using `tmux.SessionName(res.Session.ID)`. - + - Windows: existing `ZELLIJ_SOCKET_DIR=... zellij attach ` hint (unchanged). - + - +**`backend/internal/daemon/wiring_test.go`** - +- Added `runtimeselect` import. - +- `TestWiring_StartSessionBuildsSessionService`: replaced `zellij.New(zellij.Options{})` with `runtimeselect.New(nil)` so the test compiles and passes after the `startSession` signature change. - +- `TestWiring_StartLifecycleThreadsMessengerIntoLCM` and `TestDaemonZellijSocketDir_LeavesBudgetForSessionNames` still use `zellij` directly; those sub-tests are testing zellij semantics that still exist and were not changed. - + - +**`backend/internal/cli/doctor_test.go`** - +- Renamed and rewrote the three zellij-specific tool tests to cover `checkTmux` (on Darwin): - + - `TestDoctorChecksZellijVersion` -> `TestDoctorChecksTmuxVersion` - + - `TestDoctorFailsUnsupportedZellijVersion` -> `TestDoctorChecksTmuxVersionFailsOnError` - + - `TestDoctorWarnsWhenZellijMissing` -> `TestDoctorWarnsWhenTmuxMissing` - + - +## Union Interface - + - +```go - +type Runtime interface { - + ports.Runtime // Create, Destroy, IsAlive - + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error - + GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) - + AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) - +} - +``` - + - +## SessionName export - + - +`tmux.SessionName(id string) string` was already exported in Task 1 (line 267 of `tmux.go`). No change needed. - + - +## Handling of wiring_test.go - + - +The only test that called `startSession` with a concrete `*zellij.Runtime` was `TestWiring_StartSessionBuildsSessionService`. It now passes `runtimeselect.New(nil)` (which returns the tmux adapter on Darwin). Tests that still use `zellij.New` directly (`startLifecycle` call and `DefaultSocketDir` assertion) were left untouched; they test zellij semantics and compile fine because the zellij import remains. - + - +## Verification Outputs - + - +All run from `backend/`. - + - +### `go build ./...` - +``` - +Go build: Success - +``` - + - +### `GOOS=windows go build ./...` - +``` - +Go build: Success - +``` - + - +### `go test ./...` - +``` - +Go test: 1585 passed in 75 packages - +``` - + - +### `go vet ./internal/daemon/... ./internal/cli/... ./internal/adapters/runtime/...` - +``` - +Go vet: No issues found - +``` - + - +## Concerns - + - +None. All checks green, no pre-existing failures, no new dependencies, go.mod unchanged. - +95 -0 - -backend/internal/adapters/runtime/runtimeselect/runtimeselect.go - @@ -0,0 +1,46 @@ - +// Package runtimeselect picks the correct runtime backend by platform: - +// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). - +package runtimeselect - + - +import ( - + "context" - + "log/slog" - + "os" - + "runtime" - + - + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" - + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - +) - + - +// Runtime is the union interface that both tmux and zellij satisfy. - +// It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods - +// the daemon wires directly. - +type Runtime interface { - + ports.Runtime // Create, Destroy, IsAlive - + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error - + GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) - + AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) - +} - + - +// Compile-time assertions: both adapters must implement the union interface. - +var _ Runtime = (*tmux.Runtime)(nil) - +var _ Runtime = (*zellij.Runtime)(nil) - + - +// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. - +// log is used only for the Windows zellij socket-dir warning; nil is safe. - +func New(log *slog.Logger) Runtime { - + if runtime.GOOS != "windows" { - + return tmux.New(tmux.Options{}) - + } - + // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. - + dir := zellij.DefaultSocketDir() - + if dir != "" { - + if err := os.MkdirAll(dir, 0o700); err != nil { - + if log != nil { - + log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) - + } - + } - + } - + return zellij.New(zellij.Options{SocketDir: dir}) - +} - +46 -0 - -backend/internal/cli/doctor.go - @@ -10,20 +10,22 @@ import ( - + "runtime" - + - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - ) - - type doctorLevel string - - const ( - doctorPass doctorLevel = "PASS" - doctorWarn doctorLevel = "WARN" - @@ -161,21 +163,21 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { - - c.checkZellij(ctx), - + c.checkTerminalRuntime(ctx), - c.checkAOBinary(), - ) - for _, harness := range doctorHarnesses { - checks = append(checks, c.checkHarness(ctx, harness)) - } - checks = append(checks, c.checkCodexLaunchFlags(ctx), c.checkGitHubToken(ctx)) - return checks - } - - // checkStore inspects the SQLite store WITHOUT opening or migrating it. The - @@ -283,20 +285,47 @@ func (c *commandContext) checkGit(ctx context.Context) doctorCheck { - +// checkTerminalRuntime checks for the runtime multiplexer used on this platform: - +// tmux on Darwin/Linux, zellij on Windows. - +func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { - + if runtime.GOOS == "windows" { - + return c.checkZellij(ctx) - + } - + return c.checkTmux(ctx) - +} - + - +func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { - + path, err := c.deps.LookPath("tmux") - + if err != nil || path == "" { - + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"} - + } - + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) - + defer cancel() - + out, err := c.deps.CommandOutput(reqCtx, path, "-V") - + if err != nil { - + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s: %v", path, err)} - + } - + version := firstOutputLine(out) - + if version == "" { - + version = "version unknown" - + } - + return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s)", path, version)} - +} - + - func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { - path, err := c.deps.LookPath("zellij") - if err != nil || path == "" { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "zellij", Message: "not found in PATH"} - } - reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) - defer cancel() - out, err := c.deps.CommandOutput(reqCtx, path, "--version") - if err != nil { - return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} - +30 -1 - -backend/internal/cli/doctor_test.go - @@ -45,67 +45,69 @@ func TestDoctorWarnsOnUnsupportedGitVersion(t *testing.T) { - -func TestDoctorChecksZellijVersion(t *testing.T) { - +func TestDoctorChecksTmuxVersion(t *testing.T) { - setConfigEnv(t) - - c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, args ...string) ([]byte, error) { - + c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, args ...string) ([]byte, error) { - switch name { - case "/bin/git": - return []byte("git version 2.43.0\n"), nil - - case "/bin/zellij": - - if len(args) != 1 || args[0] != "--version" { - - t.Fatalf("unexpected zellij command: %s %v", name, args) - + case "/bin/tmux": - + if len(args) != 1 || args[0] != "-V" { - + t.Fatalf("unexpected tmux command: %s %v", name, args) - } - - return []byte("zellij 0.44.3\n"), nil - + return []byte("tmux 3.3a\n"), nil - default: - t.Fatalf("unexpected command: %s %v", name, args) - return nil, nil - } - }) - - - check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") - - if check.Level != doctorPass || !strings.Contains(check.Message, "0.44.3") { - - t.Fatalf("zellij check = %+v, want PASS with version", check) - + check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") - + if check.Level != doctorPass || !strings.Contains(check.Message, "3.3a") { - + t.Fatalf("tmux check = %+v, want PASS with version", check) - } - } - - -func TestDoctorFailsUnsupportedZellijVersion(t *testing.T) { - +// TestDoctorChecksTmuxVersionFailsOnError covers the case where tmux is found - +// but the version command fails. - +func TestDoctorChecksTmuxVersionFailsOnError(t *testing.T) { - setConfigEnv(t) - - c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { - + c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { - if name == "/bin/git" { - return []byte("git version 2.43.0\n"), nil - } - - return []byte("zellij 0.44.2\n"), nil - + return nil, errors.New("exec: tmux: not found") - }) - - - check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") - - if check.Level != doctorFail || !strings.Contains(check.Message, "require >= 0.44.3") { - - t.Fatalf("zellij check = %+v, want FAIL with minimum version", check) - + check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") - + if check.Level != doctorFail { - + t.Fatalf("tmux check = %+v, want FAIL on version error", check) - } - } - - -func TestDoctorWarnsWhenZellijMissing(t *testing.T) { - +func TestDoctorWarnsWhenTmuxMissing(t *testing.T) { - setConfigEnv(t) - c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { - return []byte("git version 2.43.0\n"), nil - }) - - - check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") - + check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") - if check.Level != doctorWarn { - - t.Fatalf("zellij check = %+v, want WARN", check) - + t.Fatalf("tmux check = %+v, want WARN", check) - } - } - - func TestDoctorChecksHarnessVersions(t *testing.T) { - setConfigEnv(t) - cmdPath := map[string]string{ - "git": "/bin/git", - "claude": "/bin/claude", - "codex": "/bin/codex", - } - +20 -18 - -backend/internal/cli/spawn.go - @@ -1,20 +1,22 @@ - + "runtime" - - "github.com/spf13/cobra" - "github.com/spf13/pflag" - - + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - ) - - type spawnOptions struct { - project string - harness string - branch string - prompt string - issue string - claimPR string - @@ -90,28 +92,31 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { - - // The daemon runs zellij under a short, non-default socket dir (see - - // zellij.DefaultSocketDir), so a plain `zellij attach` wouldn't find - - // the session — prefix the env so the hint is copy-pasteable. Use the - - // sanitised name zellij actually registers (zellij.SessionName): a long - - // session id maps to a different name than the raw id. - - attach := fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) - - if dir := zellij.DefaultSocketDir(); dir != "" { - - attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) - + // Print a copy-pasteable attach hint for the selected runtime. - + // On Darwin/Linux: tmux attach-session using the sanitised session name. - + // On Windows: zellij attach with the short socket dir prefix. - + var attach string - + if runtime.GOOS != "windows" { - + attach = fmt.Sprintf("tmux attach -t %s", tmux.SessionName(res.Session.ID)) - + } else { - + attach = fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) - + if dir := zellij.DefaultSocketDir(); dir != "" { - + attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) - + } - } - _, err := fmt.Fprintf(out, "attach with: %s\n", attach) - return err - }, - } - f := cmd.Flags() - // --agent is an alias for --harness so the more intuitive `ao spawn --agent - // droid` works identically; both resolve to the same harness flag. - f.SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName { - if name == "agent" { - +13 -8 - -backend/internal/daemon/daemon.go - @@ -6,21 +6,21 @@ package daemon - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/httpd" - "github.com/aoagents/agent-orchestrator/backend/internal/notify" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/preview" - "github.com/aoagents/agent-orchestrator/backend/internal/runfile" - notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" - projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" - "github.com/aoagents/agent-orchestrator/backend/internal/terminal" - @@ -75,35 +75,25 @@ func Run() error { - - // Terminal streaming: the Zellij runtime supplies the PTY-attach command and - - // liveness; the CDC broadcaster feeds the session-state channel. The manager - + // Terminal streaming: the selected runtime (tmux on macOS/Linux, zellij on Windows) supplies the - + // PTY-attach command and liveness; the CDC broadcaster feeds the session-state channel. The manager - // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow - - // through the CDC change_log — only session-state events do. - - // zellij's default socket dir is too long on macOS for long session ids - - // (see zellij.DefaultSocketDir); use a short, stable one and ensure it exists. - - zellijSocketDir := zellij.DefaultSocketDir() - - if zellijSocketDir != "" { - - if err := os.MkdirAll(zellijSocketDir, 0o700); err != nil { - - // Don't abort startup, but surface it: every spawn's zellij session - - // would otherwise fail later with an opaque socket-bind error. - - log.Warn("could not create zellij socket dir; spawns may fail", "dir", zellijSocketDir, "error", err) - - } - - } - - runtimeAdapter := zellij.New(zellij.Options{SocketDir: zellijSocketDir}) - + // through the CDC change_log -- only session-state events do. - + runtimeAdapter := runtimeselect.New(log) - termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) - defer termMgr.Close() - - // The agent messenger sends validated user input to the session's live - // zellij pane. Keep this path small until durable inbox semantics are needed. - // Built before the Lifecycle Manager so the LCM can use it for SCM-driven - // agent nudges (CI failure, review feedback, merge conflict). - messenger := newSessionMessenger(store, runtimeAdapter, log) - notificationHub := notify.NewHub() - notifier := notificationsvc.New(notificationsvc.Deps{Store: store}) - +5 -15 - -backend/internal/daemon/lifecycle_wiring.go - @@ -3,21 +3,21 @@ package daemon - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" - "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" - reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" - sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" - sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" - @@ -52,24 +52,24 @@ func startLifecycle(ctx context.Context, store *sqlite.Store, runtime ports.Runt - -// over the real zellij runtime, a per-session gitworktree workspace, the shared - +// over the selected runtime, a per-session gitworktree workspace, the shared - // store + LCM, the per-session agent resolver, and the agent messenger. The - // returned service is mounted at httpd APIDeps.Sessions. - -func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { - +func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { - defaultAgent := cfg.Agent - if defaultAgent == "" { - defaultAgent = config.DefaultAgent - } - agents, err := buildAgentResolver(defaultAgent, log) - if err != nil { - return nil, nil, err - } - ws, err := gitworktree.New(gitworktree.Options{ - // Per-session worktrees live under the data dir, so a single AO_DATA_DIR - +3 -3 - -backend/internal/daemon/wiring_test.go - @@ -3,20 +3,21 @@ package daemon - + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - telemetryadapter "github.com/aoagents/agent-orchestrator/backend/internal/adapters/telemetry" - "github.com/aoagents/agent-orchestrator/backend/internal/cdc" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" - ) - @@ -142,23 +143,23 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { - - runtime := zellij.New(zellij.Options{}) - - messenger := newSessionMessenger(store, runtime, log) - - svc, reviewSvc, err := startSession(cfg, runtime, store, lcm, messenger, telemetryadapter.NoopSink{}, log) - + rt := runtimeselect.New(nil) - + messenger := newSessionMessenger(store, rt, log) - + svc, reviewSvc, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) - if err != nil { - t.Fatalf("startSession: %v", err) - } - if svc == nil { - t.Fatal("startSession returned nil session service") - } - if reviewSvc == nil { - t.Fatal("startSession returned nil review service") - } - } - +4 -3 diff --git a/.superpowers/sdd/task-b1-brief.md b/.superpowers/sdd/task-b1-brief.md deleted file mode 100644 index 3b85582e..00000000 --- a/.superpowers/sdd/task-b1-brief.md +++ /dev/null @@ -1,119 +0,0 @@ -# Task B1: conpty protocol codec + output ring buffer (cross-platform, pure Go) - -## Goal -Create the OS-agnostic core of the Windows ConPTY runtime: the named-pipe binary -framing protocol (codec + streaming parser) and the bounded output ring buffer. -This is a faithful Go port of agent-orchestrator's TypeScript implementation. It -has NO OS-specific code and MUST be fully unit-tested and green on this Darwin -machine. No go-pty, no go-winio, no build tags in this task. - -Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, -module in `backend/`, branch `migrate-zellij-to-tmux-conpty` checked out. -Module path prefix: `github.com/aoagents/agent-orchestrator/backend`. - -Create the package: `internal/adapters/runtime/conpty` (this task adds only -`proto.go`, `ring.go`, and their tests; later tasks add the Windows pieces here). - -## Reference (port faithfully — read these) -- `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/agent-orchestrator/packages/plugins/runtime-process/src/pty-host.ts` - lines 33-92 (the MSG_* constants, `encodeMessage`, `MessageParser`) and lines - 138-178 (the rolling output buffer `appendOutput`, MAX_OUTPUT_LINES=1000). - -## Part 1 — Protocol codec (`proto.go`) -Port the binary framing protocol. Frame layout: `[1-byte type][4-byte big-endian -length][payload]`. - -Message type constants (exact values): -```go -const ( - MsgTerminalData byte = 0x01 // host -> client: raw PTY output - MsgTerminalInput byte = 0x02 // client -> host: raw keystrokes - MsgResize byte = 0x03 // client -> host: JSON {cols, rows} - MsgGetOutputReq byte = 0x04 // client -> host: JSON {lines} - MsgGetOutputRes byte = 0x05 // host -> client: UTF-8 text - MsgStatusReq byte = 0x06 // client -> host: empty - MsgStatusRes byte = 0x07 // host -> client: JSON {alive, pid, exitCode?} - MsgKillReq byte = 0x08 // client -> host: empty -) -``` - -Functions/types: -- `func EncodeMessage(msgType byte, payload []byte) []byte` — allocate `5 + - len(payload)`, write type at [0], `binary.BigEndian.PutUint32` the length at - [1:5], copy payload at [5:]. (A string convenience is not needed; callers pass - `[]byte`.) -- `type MessageParser struct { ... }` with: - - `func NewMessageParser(onMessage func(msgType byte, payload []byte)) *MessageParser` - - `func (p *MessageParser) Feed(chunk []byte)` — append to an internal buffer, - then loop: while buffered >= 5, read the BE uint32 length at [1:5], if the - full frame (5+len) is not yet buffered break; otherwise slice type+payload, - advance the buffer past the frame, and invoke onMessage. Port the exact - semantics of the TS `MessageParser.feed` (it handles arbitrary chunk - boundaries and multiple frames per chunk). - - IMPORTANT: hand `onMessage` a COPY of the payload slice (not a sub-slice of - the internal growable buffer), so a caller that retains the payload is not - corrupted when the buffer is later reused/reallocated. (The TS version slices - Buffers which are copy-on-concat; in Go you must copy explicitly.) - -Add a couple of JSON helper structs the later tasks will share (keep minimal): -```go -type ResizePayload struct { Cols int `json:"cols"`; Rows int `json:"rows"` } -type StatusPayload struct { Alive bool `json:"alive"`; PID int `json:"pid"`; ExitCode *int `json:"exitCode,omitempty"` } -type GetOutputReq struct { Lines int `json:"lines"` } -``` - -## Part 2 — Output ring buffer (`ring.go`) -Port the rolling line buffer that preserves raw bytes (ANSI codes intact) for -xterm replay, capped at `MaxOutputLines = 1000`. - -```go -type Ring struct { ... } // guarded by a sync.Mutex; safe for concurrent Append + snapshot -func NewRing() *Ring -func (r *Ring) Append(raw []byte) // port appendOutput: maintain a partialLine, split on '\n', push completed "line\n" entries, trim to last MaxOutputLines -func (r *Ring) Snapshot() []byte // join all buffered lines (for replay to a newly connected client — the full scrollback) -func (r *Ring) Tail(lines int) string // last N lines joined (for GetOutput; mirror the host MSG_GET_OUTPUT_REQ handler: start = max(0, len-lines)) -func (r *Ring) FlushPartial() // on PTY exit, push any trailing partialLine (no newline) as a final entry -``` -Semantics to match the TS `appendOutput` exactly: -- Maintain `partialLine string`. On Append: `text := partialLine + string(raw)`, - split on "\n"; the last split element is the new `partialLine` (either "" if - text ended in \n, or a partial); each earlier element is stored WITH its - trailing "\n" re-appended (`line + "\n"`). Trim oldest entries so stored line - count <= MaxOutputLines. -- `Snapshot` returns `[]byte` of all stored lines concatenated (this is what the - host sends a new client as scrollback). Do NOT include the in-progress - partialLine in Snapshot (matches TS, which only joins outputBuffer). -- `Tail(n)` joins the last n stored lines. - -## Tests (REQUIRED — the runnable check, all run on Darwin) -`proto_test.go`: -- EncodeMessage produces correct bytes for a known type+payload (assert the 5-byte - header and payload). -- MessageParser round-trips: feed a single full frame; feed two frames in one - chunk; feed a frame split across multiple Feed calls (1 byte at a time) and - confirm exactly one onMessage with correct type+payload; feed interleaved - frames of different types. Confirm payload is a copy (mutate the fed slice after - Feed and assert the delivered payload is unchanged). -- A zero-length payload frame (e.g. MsgStatusReq) parses correctly. -`ring_test.go`: -- Append partial then complete lines; Snapshot/Tail reflect the TS semantics. -- Exceeding MaxOutputLines trims the oldest. -- FlushPartial pushes a trailing no-newline line. -- Tail(n) with n greater than stored count returns all; n<=0 returns "". -- Raw bytes incl. ANSI escape sequences survive round-trip (store `\x1b[31mhi\x1b[0m\n`). - -## Definition of done (run from `backend/`) -- `go build ./...` succeeds. -- `go test ./internal/adapters/runtime/conpty/...` passes. -- `go vet ./internal/adapters/runtime/conpty/...` is clean. -- `GOOS=windows go build ./...` still succeeds (this task adds no Windows code, so - it must not break the Windows build either). - -## Hard rules -- Pure Go, no new dependencies, no build tags, no OS-specific calls in this task. -- Never use em dashes ("—") in code, comments, or commit messages. -- Minimal and idiomatic (ponytail); match the surrounding repo style. Mark any - deliberate shortcut with a `ponytail:` comment. -- Touch only files under `backend/internal/adapters/runtime/conpty/`. -- Commit on the current branch; message ends with the - `Co-Authored-By: Claude Opus 4.8 ` trailer (no em dashes). diff --git a/.superpowers/sdd/task-b1-report.md b/.superpowers/sdd/task-b1-report.md deleted file mode 100644 index f443fed0..00000000 --- a/.superpowers/sdd/task-b1-report.md +++ /dev/null @@ -1,126 +0,0 @@ -# Task B1 Report: ConPTY Protocol Codec + Output Ring Buffer - -## Status: DONE - -Commit: `1050542` - -## Files created - -- `backend/internal/adapters/runtime/conpty/proto.go` -- constants, EncodeMessage, MessageParser, shared JSON structs -- `backend/internal/adapters/runtime/conpty/ring.go` -- Ring (Append, Snapshot, Tail, FlushPartial) -- `backend/internal/adapters/runtime/conpty/proto_test.go` -- 8 table-driven tests -- `backend/internal/adapters/runtime/conpty/ring_test.go` -- 7 table-driven tests - -## Design notes - -### proto.go - -- `EncodeMessage` allocates `5+len(payload)` bytes, writes the type byte at [0], - big-endian uint32 length at [1:5], copies payload at [5:]. Exact match of - `encodeMessage` in pty-host.ts. -- `MessageParser.Feed` appends to an internal `[]byte` buffer and loops while - `len(buf) >= 5`, reading the frame length, breaking if incomplete, then - slicing type+payload, advancing the buffer past the frame, and calling - onMessage. A `make+copy` is used for the payload (not a sub-slice) so callers - that retain the slice are not corrupted on future buffer growth. -- JSON helper structs kept minimal per brief. - -### ring.go - -- `Append` prepends `partialLine`, splits on `"\n"`, stores all but the last - element as `line+"\n"`, stores the last element as the new `partialLine`. - Trims to `MaxOutputLines` after each Append. Exact match of `appendOutput`. -- `Snapshot` joins all stored lines (excludes `partialLine`), matches the - TS `outputBuffer.join("")` used for scrollback. -- `Tail(n)` slices `lines[max(0,len-n):]` and joins, mirrors the - MSG_GET_OUTPUT_REQ handler. -- `FlushPartial` pushes the partial line as a final entry (no `"\n"` appended), - mirrors the `pty.onExit` handler. -- `sync.Mutex` guards all fields; Append and Snapshot/Tail are safe to call - from separate goroutines. -- `ponytail:` comment marks the O(n) head-trim; a circular buffer would be the - upgrade path if trim rate is high. - -## Verification outputs - -### go build ./... -``` -(no output -- success) -``` - -### go test ./internal/adapters/runtime/conpty/... -v -``` -15 tests PASS ---- PASS: TestEncodeMessage ---- PASS: TestEncodeMessageZeroPayload ---- PASS: TestParserSingleFrame ---- PASS: TestParserTwoFramesOneChunk ---- PASS: TestParserByteAtATime ---- PASS: TestParserInterleavedTypes ---- PASS: TestParserPayloadIsCopy ---- PASS: TestParserZeroLengthFrame ---- PASS: TestRingAppendPartialThenComplete ---- PASS: TestRingExceedsMaxOutputLines ---- PASS: TestRingFlushPartialNoNewline ---- PASS: TestRingTailEdgeCases ---- PASS: TestRingANSIRoundTrip ---- PASS: TestRingTailSubset ---- PASS: TestRingSnapshotExcludesPartial -ok github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty -``` - -### go vet ./internal/adapters/runtime/conpty/... -``` -(no output -- clean) -``` - -### GOOS=windows go build ./... -``` -(no output -- success) -``` - -## Concerns - -None. All four verification commands pass. No new dependencies added. No build -tags. No OS-specific calls. No em dashes anywhere in code, comments, or commit -message. - ---- - -## Test-hardening follow-up (review approval, commit d67941b) - -### Changes made (test files only) - -**proto_test.go -- TestParserPayloadIsCopy (replaced)** - -The prior test mutated the fed frame slice after Feed, which did not exercise -the real aliasing risk (Feed already copies the chunk into its internal buffer -before parsing). The replacement exercises the path that would actually regress: - -1. Feed frame1 (payload "original"), capture its delivered `[]byte` pointer. -2. Feed frame2 with the same payload length ("XXXXXXXX") so the parser's - internal buffer advances over the exact byte range frame1 occupied. -3. Assert frame1's captured bytes are still "original". - -This catches a regression where payload was a raw `p.buf[5:frameLen]` subslice -instead of a `make+copy`. - -**ring_test.go -- TestRingConcurrent (added)** - -Spawns 10 writer goroutines (Append) and 10 reader goroutines (Snapshot + Tail), -each doing 100 iterations, all running concurrently on a shared Ring. They are -joined with a sync.WaitGroup. The test is a no-op without `-race`; under the -race detector any missing mutex coverage would produce a race report. No new -dependencies: `sync` was already imported by ring.go itself. - -### Verification outputs (post-hardening) - -#### go test -race ./internal/adapters/runtime/conpty/... -``` -16 passed (TestRingConcurrent is the new 16th test) -``` - -#### go vet ./internal/adapters/runtime/conpty/... -``` -(no output -- clean) -``` diff --git a/.superpowers/sdd/task-b1-review-package.txt b/.superpowers/sdd/task-b1-review-package.txt deleted file mode 100644 index 6a755569..00000000 --- a/.superpowers/sdd/task-b1-review-package.txt +++ /dev/null @@ -1,912 +0,0 @@ -=== COMMITS === -1050542 feat(conpty): add protocol codec and output ring buffer (pure Go, OS-... - -=== STAT === -backend/internal/adapters/runtime/conpty/proto.go | 89 ++++++++++ - .../internal/adapters/runtime/conpty/proto_test.go | 181 +++++++++++++++++++++ - backend/internal/adapters/runtime/conpty/ring.go | 83 ++++++++++ - .../internal/adapters/runtime/conpty/ring_test.go | 125 ++++++++++++++ - 4 files changed, 478 insertions(+) - -=== DIFF === -backend/internal/adapters/runtime/conpty/proto.go | 89 ++++++++++ - .../internal/adapters/runtime/conpty/proto_test.go | 181 +++++++++++++++++++++ - backend/internal/adapters/runtime/conpty/ring.go | 83 ++++++++++ - .../internal/adapters/runtime/conpty/ring_test.go | 125 ++++++++++++++ - 4 files changed, 478 insertions(+) - -diff --git a/backend/internal/adapters/runtime/conpty/proto.go b/backend/internal/adapters/runtime/conpty/proto.go -new file mode 100644 -index 0000000..fa47df3 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/proto.go -@@ -0,0 +1,89 @@ -+// Package conpty implements the Windows ConPTY runtime adapter for agent sessions. -+// This file contains the OS-agnostic binary framing protocol codec used by the -+// named-pipe protocol between pty-host.js and this Go client. -+// -+// Frame layout: [1-byte type][4-byte big-endian length][payload] -+package conpty -+ -+import "encoding/binary" -+ -+// Message type constants. Values must match pty-host.ts MSG_* constants exactly. -+const ( -+ MsgTerminalData byte = 0x01 // host -> client: raw PTY output -+ MsgTerminalInput byte = 0x02 // client -> host: raw keystrokes -+ MsgResize byte = 0x03 // client -> host: JSON {cols, rows} -+ MsgGetOutputReq byte = 0x04 // client -> host: JSON {lines} -+ MsgGetOutputRes byte = 0x05 // host -> client: UTF-8 text -+ MsgStatusReq byte = 0x06 // client -> host: empty -+ MsgStatusRes byte = 0x07 // host -> client: JSON {alive, pid, exitCode?} -+ MsgKillReq byte = 0x08 // client -> host: empty -+) -+ -+// JSON payload structs shared with later tasks (kept minimal). -+ -+// ResizePayload is the JSON body for MsgResize. -+type ResizePayload struct { -+ Cols int `json:"cols"` -+ Rows int `json:"rows"` -+} -+ -+// StatusPayload is the JSON body for MsgStatusRes. -+type StatusPayload struct { -+ Alive bool `json:"alive"` -+ PID int `json:"pid"` -+ ExitCode *int `json:"exitCode,omitempty"` -+} -+ -+// GetOutputReq is the JSON body for MsgGetOutputReq. -+type GetOutputReq struct { -+ Lines int `json:"lines"` -+} -+ -+// EncodeMessage encodes a single frame into the binary protocol format. -+// It allocates a fresh slice of exactly 5+len(payload) bytes. -+func EncodeMessage(msgType byte, payload []byte) []byte { -+ frame := make([]byte, 5+len(payload)) -+ frame[0] = msgType -+ binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload))) -+ copy(frame[5:], payload) -+ return frame -+} -+ -+// MessageParser is a streaming parser for the binary framing protocol. -+// It accumulates arbitrary-sized chunks from a pipe/socket stream and fires -+// onMessage exactly once per complete frame, regardless of chunk boundaries. -+// Safe to call Feed from a single goroutine; not concurrency-safe itself. -+type MessageParser struct { -+ buf []byte -+ onMessage func(msgType byte, payload []byte) -+} -+ -+// NewMessageParser returns a parser that calls onMessage for each complete frame. -+// onMessage receives a COPY of the payload so callers may retain it safely. -+func NewMessageParser(onMessage func(msgType byte, payload []byte)) *MessageParser { -+ return &MessageParser{onMessage: onMessage} -+} -+ -+// Feed appends chunk to the internal buffer and dispatches all complete frames. -+// It matches the semantics of MessageParser.feed in pty-host.ts exactly: -+// arbitrary chunk boundaries and multiple frames per chunk are both handled. -+func (p *MessageParser) Feed(chunk []byte) { -+ p.buf = append(p.buf, chunk...) -+ -+ for len(p.buf) >= 5 { -+ payloadLen := binary.BigEndian.Uint32(p.buf[1:5]) -+ frameLen := 5 + int(payloadLen) -+ if len(p.buf) < frameLen { -+ break -+ } -+ -+ msgType := p.buf[0] -+ // ponytail: explicit copy so callers that retain the slice are not -+ // corrupted when p.buf grows/reallocates on a later Feed call. -+ payload := make([]byte, payloadLen) -+ copy(payload, p.buf[5:frameLen]) -+ -+ p.buf = p.buf[frameLen:] -+ p.onMessage(msgType, payload) -+ } -+} -diff --git a/backend/internal/adapters/runtime/conpty/proto_test.go b/backend/internal/adapters/runtime/conpty/proto_test.go -new file mode 100644 -index 0000000..83cc748 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/proto_test.go -@@ -0,0 +1,181 @@ -+package conpty -+ -+import ( -+ "bytes" -+ "encoding/binary" -+ "testing" -+) -+ -+// TestEncodeMessage verifies the 5-byte header and payload are written correctly. -+func TestEncodeMessage(t *testing.T) { -+ payload := []byte("hello") -+ frame := EncodeMessage(MsgTerminalData, payload) -+ -+ if len(frame) != 5+len(payload) { -+ t.Fatalf("frame len = %d, want %d", len(frame), 5+len(payload)) -+ } -+ if frame[0] != MsgTerminalData { -+ t.Errorf("type byte = 0x%02x, want 0x%02x", frame[0], MsgTerminalData) -+ } -+ gotLen := binary.BigEndian.Uint32(frame[1:5]) -+ if int(gotLen) != len(payload) { -+ t.Errorf("length field = %d, want %d", gotLen, len(payload)) -+ } -+ if !bytes.Equal(frame[5:], payload) { -+ t.Errorf("payload = %q, want %q", frame[5:], payload) -+ } -+} -+ -+// TestEncodeMessageZeroPayload verifies a zero-length payload encodes correctly. -+func TestEncodeMessageZeroPayload(t *testing.T) { -+ frame := EncodeMessage(MsgStatusReq, nil) -+ if len(frame) != 5 { -+ t.Fatalf("frame len = %d, want 5", len(frame)) -+ } -+ if frame[0] != MsgStatusReq { -+ t.Errorf("type byte = 0x%02x, want 0x%02x", frame[0], MsgStatusReq) -+ } -+ if got := binary.BigEndian.Uint32(frame[1:5]); got != 0 { -+ t.Errorf("length field = %d, want 0", got) -+ } -+} -+ -+// collected accumulates (type, payload) pairs received by a MessageParser. -+type collected struct { -+ typ byte -+ payload []byte -+} -+ -+func collect(frames *[]collected) func(byte, []byte) { -+ return func(typ byte, payload []byte) { -+ *frames = append(*frames, collected{typ, payload}) -+ } -+} -+ -+// TestParserSingleFrame feeds one complete frame and expects one callback. -+func TestParserSingleFrame(t *testing.T) { -+ var got []collected -+ p := NewMessageParser(collect(&got)) -+ -+ p.Feed(EncodeMessage(MsgTerminalData, []byte("hi"))) -+ -+ if len(got) != 1 { -+ t.Fatalf("got %d messages, want 1", len(got)) -+ } -+ if got[0].typ != MsgTerminalData { -+ t.Errorf("type = 0x%02x, want 0x%02x", got[0].typ, MsgTerminalData) -+ } -+ if !bytes.Equal(got[0].payload, []byte("hi")) { -+ t.Errorf("payload = %q, want %q", got[0].payload, "hi") -+ } -+} -+ -+// TestParserTwoFramesOneChunk feeds two frames concatenated and expects two callbacks. -+func TestParserTwoFramesOneChunk(t *testing.T) { -+ var got []collected -+ p := NewMessageParser(collect(&got)) -+ -+ chunk := append(EncodeMessage(MsgTerminalData, []byte("frame1")), -+ EncodeMessage(MsgTerminalInput, []byte("frame2"))...) -+ p.Feed(chunk) -+ -+ if len(got) != 2 { -+ t.Fatalf("got %d messages, want 2", len(got)) -+ } -+ if got[0].typ != MsgTerminalData || string(got[0].payload) != "frame1" { -+ t.Errorf("message 0 = {%02x, %q}", got[0].typ, got[0].payload) -+ } -+ if got[1].typ != MsgTerminalInput || string(got[1].payload) != "frame2" { -+ t.Errorf("message 1 = {%02x, %q}", got[1].typ, got[1].payload) -+ } -+} -+ -+// TestParserByteAtATime feeds one frame one byte at a time and expects exactly -+// one callback with the correct type and payload. -+func TestParserByteAtATime(t *testing.T) { -+ var got []collected -+ p := NewMessageParser(collect(&got)) -+ -+ frame := EncodeMessage(MsgResize, []byte(`{"cols":80,"rows":24}`)) -+ for _, b := range frame { -+ p.Feed([]byte{b}) -+ } -+ -+ if len(got) != 1 { -+ t.Fatalf("got %d messages, want 1", len(got)) -+ } -+ if got[0].typ != MsgResize { -+ t.Errorf("type = 0x%02x, want 0x%02x", got[0].typ, MsgResize) -+ } -+ want := []byte(`{"cols":80,"rows":24}`) -+ if !bytes.Equal(got[0].payload, want) { -+ t.Errorf("payload = %q, want %q", got[0].payload, want) -+ } -+} -+ -+// TestParserInterleavedTypes feeds frames of different types and verifies order. -+func TestParserInterleavedTypes(t *testing.T) { -+ types := []byte{MsgStatusReq, MsgKillReq, MsgGetOutputReq, MsgStatusRes} -+ payloads := [][]byte{nil, nil, []byte(`{"lines":10}`), []byte(`{"alive":true,"pid":42}`)} -+ -+ var chunk []byte -+ for i, typ := range types { -+ chunk = append(chunk, EncodeMessage(typ, payloads[i])...) -+ } -+ -+ var got []collected -+ p := NewMessageParser(collect(&got)) -+ p.Feed(chunk) -+ -+ if len(got) != len(types) { -+ t.Fatalf("got %d messages, want %d", len(got), len(types)) -+ } -+ for i, g := range got { -+ if g.typ != types[i] { -+ t.Errorf("[%d] type = 0x%02x, want 0x%02x", i, g.typ, types[i]) -+ } -+ if !bytes.Equal(g.payload, payloads[i]) { -+ t.Errorf("[%d] payload = %q, want %q", i, g.payload, payloads[i]) -+ } -+ } -+} -+ -+// TestParserPayloadIsCopy verifies that mutating the fed slice after Feed does -+// NOT corrupt the payload delivered to onMessage. -+func TestParserPayloadIsCopy(t *testing.T) { -+ var got []collected -+ p := NewMessageParser(collect(&got)) -+ -+ src := []byte("original") -+ frame := EncodeMessage(MsgTerminalData, src) -+ p.Feed(frame) -+ -+ // Mutate the frame bytes that were fed. -+ for i := range frame { -+ frame[i] = 0xFF -+ } -+ -+ if len(got) != 1 { -+ t.Fatalf("got %d messages, want 1", len(got)) -+ } -+ if !bytes.Equal(got[0].payload, []byte("original")) { -+ t.Errorf("payload corrupted after mutating fed slice: got %q", got[0].payload) -+ } -+} -+ -+// TestParserZeroLengthFrame verifies a zero-payload frame (e.g. MsgStatusReq) parses. -+func TestParserZeroLengthFrame(t *testing.T) { -+ var got []collected -+ p := NewMessageParser(collect(&got)) -+ p.Feed(EncodeMessage(MsgStatusReq, nil)) -+ -+ if len(got) != 1 { -+ t.Fatalf("got %d messages, want 1", len(got)) -+ } -+ if got[0].typ != MsgStatusReq { -+ t.Errorf("type = 0x%02x, want 0x%02x", got[0].typ, MsgStatusReq) -+ } -+ if len(got[0].payload) != 0 { -+ t.Errorf("payload len = %d, want 0", len(got[0].payload)) -+ } -+} -diff --git a/backend/internal/adapters/runtime/conpty/ring.go b/backend/internal/adapters/runtime/conpty/ring.go -new file mode 100644 -index 0000000..06d28c1 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/ring.go -@@ -0,0 +1,83 @@ -+package conpty -+ -+import ( -+ "strings" -+ "sync" -+) -+ -+// MaxOutputLines is the rolling line-buffer cap, matching MAX_OUTPUT_LINES in pty-host.ts. -+const MaxOutputLines = 1000 -+ -+// Ring is a bounded rolling buffer of terminal output lines, ANSI codes preserved. -+// It mirrors the appendOutput state machine from pty-host.ts. -+// Concurrent Append and Snapshot/Tail calls are safe. -+type Ring struct { -+ mu sync.Mutex -+ lines []string // each entry is "line\n" (or bare text on FlushPartial) -+ partialLine string -+} -+ -+// NewRing returns an empty Ring. -+func NewRing() *Ring { -+ return &Ring{} -+} -+ -+// Append mirrors appendOutput from pty-host.ts: prepend the current partialLine, -+// split on newlines, store completed lines with "\n" re-appended, keep the last -+// element as the new partialLine, then trim to MaxOutputLines. -+func (r *Ring) Append(raw []byte) { -+ r.mu.Lock() -+ defer r.mu.Unlock() -+ -+ text := r.partialLine + string(raw) -+ parts := strings.Split(text, "\n") -+ // The last element is either "" (text ended with \n) or an incomplete line. -+ r.partialLine = parts[len(parts)-1] -+ for _, line := range parts[:len(parts)-1] { -+ r.lines = append(r.lines, line+"\n") -+ } -+ if len(r.lines) > MaxOutputLines { -+ // ponytail: slice off the head; ceiling: O(n) copy on every trim cycle. -+ // Upgrade path: circular buffer if trim rate is very high. -+ r.lines = r.lines[len(r.lines)-MaxOutputLines:] -+ } -+} -+ -+// FlushPartial pushes any in-progress partial line as a final entry. -+// Called on PTY exit to mirror the pty-host.ts onExit handler. -+func (r *Ring) FlushPartial() { -+ r.mu.Lock() -+ defer r.mu.Unlock() -+ -+ if r.partialLine == "" { -+ return -+ } -+ r.lines = append(r.lines, r.partialLine) -+ r.partialLine = "" -+} -+ -+// Snapshot returns all stored lines concatenated as raw bytes for scrollback replay. -+// The in-progress partialLine is NOT included (matches TS outputBuffer.join("")). -+func (r *Ring) Snapshot() []byte { -+ r.mu.Lock() -+ defer r.mu.Unlock() -+ -+ return []byte(strings.Join(r.lines, "")) -+} -+ -+// Tail returns the last n stored lines joined as a string. -+// Mirrors the MSG_GET_OUTPUT_REQ handler: start = max(0, len-lines). -+// n <= 0 returns "". -+func (r *Ring) Tail(n int) string { -+ r.mu.Lock() -+ defer r.mu.Unlock() -+ -+ if n <= 0 { -+ return "" -+ } -+ start := len(r.lines) - n -+ if start < 0 { -+ start = 0 -+ } -+ return strings.Join(r.lines[start:], "") -+} -diff --git a/backend/internal/adapters/runtime/conpty/ring_test.go b/backend/internal/adapters/runtime/conpty/ring_test.go -new file mode 100644 -index 0000000..d954ee3 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/ring_test.go -@@ -0,0 +1,125 @@ -+package conpty -+ -+import ( -+ "strings" -+ "testing" -+) -+ -+// TestRingAppendPartialThenComplete verifies partial-line accumulation and -+// that Snapshot/Tail reflect only completed lines. -+func TestRingAppendPartialThenComplete(t *testing.T) { -+ r := NewRing() -+ r.Append([]byte("hel")) -+ r.Append([]byte("lo\nwor")) -+ -+ snap := string(r.Snapshot()) -+ if snap != "hello\n" { -+ t.Errorf("Snapshot = %q, want %q", snap, "hello\n") -+ } -+ -+ tail := r.Tail(10) -+ if tail != "hello\n" { -+ t.Errorf("Tail(10) = %q, want %q", tail, "hello\n") -+ } -+ -+ // Flush the partial "wor" -+ r.FlushPartial() -+ snap = string(r.Snapshot()) -+ if snap != "hello\nwor" { -+ t.Errorf("after FlushPartial Snapshot = %q, want %q", snap, "hello\nwor") -+ } -+} -+ -+// TestRingExceedsMaxOutputLines verifies the buffer trims to MaxOutputLines. -+func TestRingExceedsMaxOutputLines(t *testing.T) { -+ r := NewRing() -+ // Push 1005 lines. -+ for i := 0; i < 1005; i++ { -+ r.Append([]byte("x\n")) -+ } -+ -+ snap := r.Snapshot() -+ got := strings.Count(string(snap), "\n") -+ if got != MaxOutputLines { -+ t.Errorf("stored %d lines, want %d", got, MaxOutputLines) -+ } -+} -+ -+// TestRingFlushPartialNoNewline verifies FlushPartial pushes a trailing line. -+func TestRingFlushPartialNoNewline(t *testing.T) { -+ r := NewRing() -+ r.Append([]byte("line1\npartial")) -+ r.FlushPartial() -+ -+ snap := string(r.Snapshot()) -+ if !strings.Contains(snap, "partial") { -+ t.Errorf("Snapshot missing 'partial': %q", snap) -+ } -+ -+ // Calling FlushPartial again is a no-op. -+ r.FlushPartial() -+ snap2 := string(r.Snapshot()) -+ if snap2 != snap { -+ t.Errorf("second FlushPartial changed snapshot: %q -> %q", snap, snap2) -+ } -+} -+ -+// TestRingTailEdgeCases covers n > stored count and n <= 0. -+func TestRingTailEdgeCases(t *testing.T) { -+ r := NewRing() -+ r.Append([]byte("a\nb\n")) -+ -+ if got := r.Tail(100); got != "a\nb\n" { -+ t.Errorf("Tail(100) = %q, want %q", got, "a\nb\n") -+ } -+ if got := r.Tail(0); got != "" { -+ t.Errorf("Tail(0) = %q, want empty", got) -+ } -+ if got := r.Tail(-1); got != "" { -+ t.Errorf("Tail(-1) = %q, want empty", got) -+ } -+} -+ -+// TestRingANSIRoundTrip verifies raw ANSI escape sequences survive storage intact. -+func TestRingANSIRoundTrip(t *testing.T) { -+ ansi := "\x1b[31mhi\x1b[0m\n" -+ r := NewRing() -+ r.Append([]byte(ansi)) -+ -+ snap := string(r.Snapshot()) -+ if snap != ansi { -+ t.Errorf("Snapshot = %q, want %q", snap, ansi) -+ } -+ tail := r.Tail(1) -+ if tail != ansi { -+ t.Errorf("Tail(1) = %q, want %q", tail, ansi) -+ } -+} -+ -+// TestRingTailSubset verifies Tail returns exactly the last n lines. -+func TestRingTailSubset(t *testing.T) { -+ r := NewRing() -+ for i := 0; i < 10; i++ { -+ r.Append([]byte("line\n")) -+ } -+ -+ tail3 := r.Tail(3) -+ if got := strings.Count(tail3, "\n"); got != 3 { -+ t.Errorf("Tail(3) contains %d newlines, want 3", got) -+ } -+} -+ -+// TestRingSnapshotExcludesPartial verifies the in-progress partial line is NOT -+// included in Snapshot (matches TS semantics: only outputBuffer, not partialLine). -+func TestRingSnapshotExcludesPartial(t *testing.T) { -+ r := NewRing() -+ r.Append([]byte("complete\npartial")) -+ -+ snap := string(r.Snapshot()) -+ if strings.Contains(snap, "partial") { -+ t.Errorf("Snapshot includes partial line: %q", snap) -+ } -+ if !strings.Contains(snap, "complete\n") { -+ t.Errorf("Snapshot missing complete line: %q", snap) -+ } -+} - ---- Changes --- - -backend/internal/adapters/runtime/conpty/proto.go - @@ -0,0 +1,89 @@ - +// Package conpty implements the Windows ConPTY runtime adapter for agent sessions. - +// This file contains the OS-agnostic binary framing protocol codec used by the - +// named-pipe protocol between pty-host.js and this Go client. - +// - +// Frame layout: [1-byte type][4-byte big-endian length][payload] - +package conpty - + - +import "encoding/binary" - + - +// Message type constants. Values must match pty-host.ts MSG_* constants exactly. - +const ( - + MsgTerminalData byte = 0x01 // host -> client: raw PTY output - + MsgTerminalInput byte = 0x02 // client -> host: raw keystrokes - + MsgResize byte = 0x03 // client -> host: JSON {cols, rows} - + MsgGetOutputReq byte = 0x04 // client -> host: JSON {lines} - + MsgGetOutputRes byte = 0x05 // host -> client: UTF-8 text - + MsgStatusReq byte = 0x06 // client -> host: empty - + MsgStatusRes byte = 0x07 // host -> client: JSON {alive, pid, exitCode?} - + MsgKillReq byte = 0x08 // client -> host: empty - +) - + - +// JSON payload structs shared with later tasks (kept minimal). - + - +// ResizePayload is the JSON body for MsgResize. - +type ResizePayload struct { - + Cols int `json:"cols"` - + Rows int `json:"rows"` - +} - + - +// StatusPayload is the JSON body for MsgStatusRes. - +type StatusPayload struct { - + Alive bool `json:"alive"` - + PID int `json:"pid"` - + ExitCode *int `json:"exitCode,omitempty"` - +} - + - +// GetOutputReq is the JSON body for MsgGetOutputReq. - +type GetOutputReq struct { - + Lines int `json:"lines"` - +} - + - +// EncodeMessage encodes a single frame into the binary protocol format. - +// It allocates a fresh slice of exactly 5+len(payload) bytes. - +func EncodeMessage(msgType byte, payload []byte) []byte { - + frame := make([]byte, 5+len(payload)) - + frame[0] = msgType - + binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload))) - + copy(frame[5:], payload) - + return frame - +} - + - +// MessageParser is a streaming parser for the binary framing protocol. - +// It accumulates arbitrary-sized chunks from a pipe/socket stream and fires - +// onMessage exactly once per complete frame, regardless of chunk boundaries. - +// Safe to call Feed from a single goroutine; not concurrency-safe itself. - +type MessageParser struct { - + buf []byte - + onMessage func(msgType byte, payload []byte) - +} - + - +// NewMessageParser returns a parser that calls onMessage for each complete frame. - +// onMessage receives a COPY of the payload so callers may retain it safely. - +func NewMessageParser(onMessage func(msgType byte, payload []byte)) *MessageParser { - + return &MessageParser{onMessage: onMessage} - +} - + - +// Feed appends chunk to the internal buffer and dispatches all complete frames. - +// It matches the semantics of MessageParser.feed in pty-host.ts exactly: - +// arbitrary chunk boundaries and multiple frames per chunk are both handled. - +func (p *MessageParser) Feed(chunk []byte) { - + p.buf = append(p.buf, chunk...) - + - + for len(p.buf) >= 5 { - + payloadLen := binary.BigEndian.Uint32(p.buf[1:5]) - + frameLen := 5 + int(payloadLen) - + if len(p.buf) < frameLen { - + break - + } - + - + msgType := p.buf[0] - + // ponytail: explicit copy so callers that retain the slice are not - + // corrupted when p.buf grows/reallocates on a later Feed call. - + payload := make([]byte, payloadLen) - + copy(payload, p.buf[5:frameLen]) - + - + p.buf = p.buf[frameLen:] - + p.onMessage(msgType, payload) - + } - +} - +89 -0 - -backend/internal/adapters/runtime/conpty/proto_test.go - @@ -0,0 +1,181 @@ - +package conpty - + - +import ( - + "bytes" - + "encoding/binary" - + "testing" - +) - + - +// TestEncodeMessage verifies the 5-byte header and payload are written correctly. - +func TestEncodeMessage(t *testing.T) { - + payload := []byte("hello") - + frame := EncodeMessage(MsgTerminalData, payload) - + - + if len(frame) != 5+len(payload) { - + t.Fatalf("frame len = %d, want %d", len(frame), 5+len(payload)) - + } - + if frame[0] != MsgTerminalData { - + t.Errorf("type byte = 0x%02x, want 0x%02x", frame[0], MsgTerminalData) - + } - + gotLen := binary.BigEndian.Uint32(frame[1:5]) - + if int(gotLen) != len(payload) { - + t.Errorf("length field = %d, want %d", gotLen, len(payload)) - + } - + if !bytes.Equal(frame[5:], payload) { - + t.Errorf("payload = %q, want %q", frame[5:], payload) - + } - +} - + - +// TestEncodeMessageZeroPayload verifies a zero-length payload encodes correctly. - +func TestEncodeMessageZeroPayload(t *testing.T) { - + frame := EncodeMessage(MsgStatusReq, nil) - + if len(frame) != 5 { - + t.Fatalf("frame len = %d, want 5", len(frame)) - + } - + if frame[0] != MsgStatusReq { - + t.Errorf("type byte = 0x%02x, want 0x%02x", frame[0], MsgStatusReq) - + } - + if got := binary.BigEndian.Uint32(frame[1:5]); got != 0 { - + t.Errorf("length field = %d, want 0", got) - + } - +} - + - +// collected accumulates (type, payload) pairs received by a MessageParser. - +type collected struct { - + typ byte - + payload []byte - +} - + - +func collect(frames *[]collected) func(byte, []byte) { - + return func(typ byte, payload []byte) { - + *frames = append(*frames, collected{typ, payload}) - + } - +} - + - +// TestParserSingleFrame feeds one complete frame and expects one callback. - +func TestParserSingleFrame(t *testing.T) { - + var got []collected - + p := NewMessageParser(collect(&got)) - + - + p.Feed(EncodeMessage(MsgTerminalData, []byte("hi"))) - + - + if len(got) != 1 { - + t.Fatalf("got %d messages, want 1", len(got)) - + } - + if got[0].typ != MsgTerminalData { - + t.Errorf("type = 0x%02x, want 0x%02x", got[0].typ, MsgTerminalData) - + } - + if !bytes.Equal(got[0].payload, []byte("hi")) { - + t.Errorf("payload = %q, want %q", got[0].payload, "hi") - + } - +} - + - +// TestParserTwoFramesOneChunk feeds two frames concatenated and expects two callbacks. - +func TestParserTwoFramesOneChunk(t *testing.T) { - + var got []collected - + p := NewMessageParser(collect(&got)) - + - + chunk := append(EncodeMessage(MsgTerminalData, []byte("frame1")), - + EncodeMessage(MsgTerminalInput, []byte("frame2"))...) - + p.Feed(chunk) - + - + if len(got) != 2 { - + t.Fatalf("got %d messages, want 2", len(got)) - + } - + if got[0].typ != MsgTerminalData || string(got[0].payload) != "frame1" { - + t.Errorf("message 0 = {%02x, %q}", got[0].typ, got[0].payload) - + } - + if got[1].typ != MsgTerminalInput || string(got[1].payload) != "frame2" { - + t.Errorf("message 1 = {%02x, %q}", got[1].typ, got[1].payload) - + } - +} - + - +// TestParserByteAtATime feeds one frame one byte at a time and expects exactly - +// one callback with the correct type and payload. - +func TestParserByteAtATime(t *testing.T) { - + var got []collected - + p := NewMessageParser(collect(&got)) - + - + frame := EncodeMessage(MsgResize, []byte(`{"cols":80,"rows":24}`)) - + for _, b := range frame { - ... (81 lines truncated) - +181 -0 - -backend/internal/adapters/runtime/conpty/ring.go - @@ -0,0 +1,83 @@ - +package conpty - + - +import ( - + "strings" - + "sync" - +) - + - +// MaxOutputLines is the rolling line-buffer cap, matching MAX_OUTPUT_LINES in pty-host.ts. - +const MaxOutputLines = 1000 - + - +// Ring is a bounded rolling buffer of terminal output lines, ANSI codes preserved. - +// It mirrors the appendOutput state machine from pty-host.ts. - +// Concurrent Append and Snapshot/Tail calls are safe. - +type Ring struct { - + mu sync.Mutex - + lines []string // each entry is "line\n" (or bare text on FlushPartial) - + partialLine string - +} - + - +// NewRing returns an empty Ring. - +func NewRing() *Ring { - + return &Ring{} - +} - + - +// Append mirrors appendOutput from pty-host.ts: prepend the current partialLine, - +// split on newlines, store completed lines with "\n" re-appended, keep the last - +// element as the new partialLine, then trim to MaxOutputLines. - +func (r *Ring) Append(raw []byte) { - + r.mu.Lock() - + defer r.mu.Unlock() - + - + text := r.partialLine + string(raw) - + parts := strings.Split(text, "\n") - + // The last element is either "" (text ended with \n) or an incomplete line. - + r.partialLine = parts[len(parts)-1] - + for _, line := range parts[:len(parts)-1] { - + r.lines = append(r.lines, line+"\n") - + } - + if len(r.lines) > MaxOutputLines { - + // ponytail: slice off the head; ceiling: O(n) copy on every trim cycle. - + // Upgrade path: circular buffer if trim rate is very high. - + r.lines = r.lines[len(r.lines)-MaxOutputLines:] - + } - +} - + - +// FlushPartial pushes any in-progress partial line as a final entry. - +// Called on PTY exit to mirror the pty-host.ts onExit handler. - +func (r *Ring) FlushPartial() { - + r.mu.Lock() - + defer r.mu.Unlock() - + - + if r.partialLine == "" { - + return - + } - + r.lines = append(r.lines, r.partialLine) - + r.partialLine = "" - +} - + - +// Snapshot returns all stored lines concatenated as raw bytes for scrollback replay. - +// The in-progress partialLine is NOT included (matches TS outputBuffer.join("")). - +func (r *Ring) Snapshot() []byte { - + r.mu.Lock() - + defer r.mu.Unlock() - + - + return []byte(strings.Join(r.lines, "")) - +} - + - +// Tail returns the last n stored lines joined as a string. - +// Mirrors the MSG_GET_OUTPUT_REQ handler: start = max(0, len-lines). - +// n <= 0 returns "". - +func (r *Ring) Tail(n int) string { - + r.mu.Lock() - + defer r.mu.Unlock() - + - + if n <= 0 { - + return "" - + } - + start := len(r.lines) - n - + if start < 0 { - + start = 0 - + } - + return strings.Join(r.lines[start:], "") - +} - +83 -0 - -backend/internal/adapters/runtime/conpty/ring_test.go - @@ -0,0 +1,125 @@ - +package conpty - + - +import ( - + "strings" - + "testing" - +) - + - +// TestRingAppendPartialThenComplete verifies partial-line accumulation and - +// that Snapshot/Tail reflect only completed lines. - +func TestRingAppendPartialThenComplete(t *testing.T) { - + r := NewRing() - + r.Append([]byte("hel")) - + r.Append([]byte("lo\nwor")) - + - + snap := string(r.Snapshot()) - + if snap != "hello\n" { - + t.Errorf("Snapshot = %q, want %q", snap, "hello\n") - + } - + - + tail := r.Tail(10) - + if tail != "hello\n" { - + t.Errorf("Tail(10) = %q, want %q", tail, "hello\n") - + } - + - + // Flush the partial "wor" - + r.FlushPartial() - + snap = string(r.Snapshot()) - + if snap != "hello\nwor" { - + t.Errorf("after FlushPartial Snapshot = %q, want %q", snap, "hello\nwor") - + } - +} - + - +// TestRingExceedsMaxOutputLines verifies the buffer trims to MaxOutputLines. - +func TestRingExceedsMaxOutputLines(t *testing.T) { - + r := NewRing() - + // Push 1005 lines. - + for i := 0; i < 1005; i++ { - + r.Append([]byte("x\n")) - + } - + - + snap := r.Snapshot() - + got := strings.Count(string(snap), "\n") - + if got != MaxOutputLines { - + t.Errorf("stored %d lines, want %d", got, MaxOutputLines) - + } - +} - + - +// TestRingFlushPartialNoNewline verifies FlushPartial pushes a trailing line. - +func TestRingFlushPartialNoNewline(t *testing.T) { - + r := NewRing() - + r.Append([]byte("line1\npartial")) - + r.FlushPartial() - + - + snap := string(r.Snapshot()) - + if !strings.Contains(snap, "partial") { - + t.Errorf("Snapshot missing 'partial': %q", snap) - + } - + - + // Calling FlushPartial again is a no-op. - + r.FlushPartial() - + snap2 := string(r.Snapshot()) - + if snap2 != snap { - + t.Errorf("second FlushPartial changed snapshot: %q -> %q", snap, snap2) - + } - +} - + - +// TestRingTailEdgeCases covers n > stored count and n <= 0. - +func TestRingTailEdgeCases(t *testing.T) { - + r := NewRing() - + r.Append([]byte("a\nb\n")) - + - + if got := r.Tail(100); got != "a\nb\n" { - + t.Errorf("Tail(100) = %q, want %q", got, "a\nb\n") - + } - + if got := r.Tail(0); got != "" { - + t.Errorf("Tail(0) = %q, want empty", got) - + } - + if got := r.Tail(-1); got != "" { - + t.Errorf("Tail(-1) = %q, want empty", got) - + } - +} - + - +// TestRingANSIRoundTrip verifies raw ANSI escape sequences survive storage intact. - +func TestRingANSIRoundTrip(t *testing.T) { - + ansi := "\x1b[31mhi\x1b[0m\n" - + r := NewRing() - + r.Append([]byte(ansi)) - + - + snap := string(r.Snapshot()) - + if snap != ansi { - + t.Errorf("Snapshot = %q, want %q", snap, ansi) - + } - + tail := r.Tail(1) - + if tail != ansi { - + t.Errorf("Tail(1) = %q, want %q", tail, ansi) - + } - +} - + - +// TestRingTailSubset verifies Tail returns exactly the last n lines. - +func TestRingTailSubset(t *testing.T) { - ... (25 lines truncated) - +125 -0 -[full diff: rtk git diff --no-compact] diff --git a/.superpowers/sdd/task-b2-brief.md b/.superpowers/sdd/task-b2-brief.md deleted file mode 100644 index 89007f5d..00000000 --- a/.superpowers/sdd/task-b2-brief.md +++ /dev/null @@ -1,102 +0,0 @@ -# Task B2: Windows pty-host registry (sideband JSON, cross-platform-testable) - -## Goal -Port agent-orchestrator's `windows-pty-registry.ts` to Go: a flat JSON sideband -list of live pty-host processes so a stop/sweep can find and graceful-kill them -even when session metadata is lost. The JSON read/write/prune logic is pure Go and -MUST be fully unit-tested and green on this Darwin machine; only the PID-liveness -probe is OS-specific and is isolated behind a tiny build-tagged helper. - -Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, -module in `backend/`, branch `migrate-zellij-to-tmux-conpty`. Module prefix: -`github.com/aoagents/agent-orchestrator/backend`. - -New package: `internal/adapters/runtime/conpty/ptyregistry` -(separate sub-package so it has no dependency on the Windows ConPTY code). - -## Reference (port faithfully — read it) -`/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/agent-orchestrator/packages/core/src/windows-pty-registry.ts` -(entry shape, register/unregister/getWindowsPtyHosts with auto-prune, clear, -atomic write, isAlive via process.kill(pid,0) with EPERM-means-alive). - -## Registry file location -`~/.ao/windows-pty-hosts.json` (agent-orchestrator uses ~/.agent-orchestrator; -ReverbCode's AO home is `~/.ao`). Resolve via `os.UserHomeDir()` joined with -`.ao` and the filename. Resolve it through a small unexported function (NOT a -package-level const) so tests can point HOME at a tempdir (`t.Setenv("HOME", dir)` -on Unix) and exercise real read/write. ponytail: HOME-based resolution is enough; -do not thread an AO_DATA_DIR override here unless a consumer needs it. - -## API (exported) -```go -type Entry struct { - SessionID string `json:"sessionId"` - PtyHostPID int `json:"ptyHostPid"` - PipePath string `json:"pipePath"` - RegisteredAt string `json:"registeredAt"` // RFC3339; caller passes the timestamp -} - -// Register adds or replaces the entry for entry.SessionID. registeredAt is set by -// the caller (pass time.Now().UTC().Format(time.RFC3339)) — do NOT call time.Now() -// inside the registry, so it stays deterministic/testable. Accept it as a param. -func Register(entry Entry) error - -// Unregister removes the entry for sessionID. No-op if absent. -func Unregister(sessionID string) error - -// List returns all entries whose PtyHostPID is still alive, auto-pruning dead -// ones (rewrites the file if any were pruned). Liveness uses pidAlive (below). -func List() ([]Entry, error) - -// Clear deletes the registry file (best-effort; used by tests/recovery). -func Clear() error -``` -Behavior to match the TS: -- Read tolerates a missing file (returns empty), and a malformed/non-array file - (returns empty rather than erroring) — mirror `readRaw`'s defensive filter that - drops entries missing sessionId/ptyHostPid/pipePath. -- Write is atomic: write to a temp file in the same dir then `os.Rename` over the - target (rename is atomic on the same filesystem). Create `~/.ao` with 0o700 if - missing. When the resulting list is empty, delete the file instead of writing - `[]` (mirror `writeRaw`). -- Register replaces any existing entry with the same SessionID (filter-then-append). - -## OS-specific PID liveness (isolate behind build tags) -A package-level `var pidAlive = defaultPidAlive` that List uses, so tests override -it with a fake. Provide `defaultPidAlive` in two build-tagged files: -- `pidalive_unix.go` (`//go:build !windows`): use `syscall.Kill(pid, 0)`; treat - `nil` and `EPERM` as alive, `ESRCH` (and anything else) as dead. (Mirrors - process.kill(pid,0) with EPERM-means-alive.) -- `pidalive_windows.go` (`//go:build windows`): port the EPERM-means-alive intent - using the Windows API. Use `golang.org/x/sys/windows` (already a dependency): - `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))`; if it - succeeds the process is alive (CloseHandle and return true). If it fails with - ERROR_ACCESS_DENIED treat as ALIVE (exists but not queryable), otherwise dead. - This file is compile-checked via GOOS=windows but not run here; keep it small and - obviously correct. - -## Tests (`ptyregistry_test.go`, run on Darwin) -Point HOME at `t.TempDir()` and override `pidAlive` with a fake controlled by the -test. Cover: -- Register then List returns the entry (with the fake marking it alive). -- Register replaces a same-sessionID entry (no duplicate). -- Unregister removes; no-op when absent. -- List prunes entries whose pid the fake reports dead, and rewrites the file - (verify by reading the file back / a second List). -- Empty result deletes the file (Clear, or unregistering the last entry). -- Malformed JSON file -> List returns empty, no error. -- Missing file -> List returns empty, no error. -- Atomicity smoke: Register writes valid JSON parseable back into []Entry. - -## Definition of done (from `backend/`) -- `go build ./...` ; `GOOS=windows go build ./...` ; `GOOS=linux go build ./...` all succeed. -- `go test ./internal/adapters/runtime/conpty/ptyregistry/...` passes. -- `go vet ./internal/adapters/runtime/conpty/ptyregistry/...` clean. - -## Hard rules -- Touch only files under `backend/internal/adapters/runtime/conpty/ptyregistry/`. -- The only dependency allowed is the already-present `golang.org/x/sys/windows` - (windows file only). go.mod must not gain a NEW module. -- Never use em dashes ("—") anywhere. Minimal/idiomatic (ponytail). -- Commit on the current branch; message ends with the - `Co-Authored-By: Claude Opus 4.8 ` trailer. diff --git a/.superpowers/sdd/task-b2-report.md b/.superpowers/sdd/task-b2-report.md deleted file mode 100644 index e15def0b..00000000 --- a/.superpowers/sdd/task-b2-report.md +++ /dev/null @@ -1,55 +0,0 @@ -# Task B2 Report: Windows pty-host registry - -## Status: DONE - -## Files created (all under `backend/internal/adapters/runtime/conpty/ptyregistry/`) - -- `registry.go` - main package: `Entry` struct, `Register`, `Unregister`, `List`, `Clear`, `readRaw`, `writeRaw`, `registryFile`, `pidAlive` var. -- `pidalive_unix.go` (`//go:build !windows`) - `defaultPidAlive` via `syscall.Kill(pid, 0)`; nil and EPERM = alive, ESRCH = dead. -- `pidalive_windows.go` (`//go:build windows`) - `defaultPidAlive` via `windows.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, ...)`; success or ERROR_ACCESS_DENIED = alive. -- `ptyregistry_test.go` - 10 tests; all run on Darwin with HOME pointed at `t.TempDir()` and `pidAlive` overridden with a fake. - -## Build-tag split - -`pidalive_unix.go` covers Darwin, Linux, and every non-Windows target via `!windows`. `pidalive_windows.go` covers Windows only. The package var `var pidAlive = defaultPidAlive` in `registry.go` is the single injection point; tests replace it with a per-test closure. - -## Verification outputs (all from `backend/`) - -### `go build ./...` -``` -Go build: Success -``` - -### `GOOS=windows go build ./...` -``` -Go build: Success -``` - -### `GOOS=linux go build ./...` -``` -Go build: Success -``` - -### `go test ./internal/adapters/runtime/conpty/ptyregistry/...` -``` -Go test: 10 passed in 1 packages -``` -Tests covered: Register+List, register-replaces-same-ID, Unregister-removes, Unregister-no-op-when-absent, List-prunes-dead-PIDs (and rewrites file), empty-result-deletes-file, Clear-deletes-file, malformed-JSON-returns-empty, missing-file-returns-empty, atomic-write-produces-valid-JSON. - -### `go vet ./internal/adapters/runtime/conpty/ptyregistry/...` -``` -Go vet: No issues found -``` - -## Design fidelity notes - -- `readRaw` drops entries missing sessionId, ptyHostPid, or pipePath (mirrors TS filter). -- `writeRaw` uses temp-file + `os.Rename` (atomic on same filesystem); deletes the file when list is empty (mirrors TS `writeRaw`). -- `Register` is filter-then-append (same-SessionID replaced, not duplicated). -- `registeredAt` is caller-supplied; `time.Now()` is never called inside the registry. -- `registryFile()` is a function (not a const), so `t.Setenv("HOME", dir)` redirects all I/O. -- `golang.org/x/sys/windows` used only in `pidalive_windows.go`; no new go.mod dependency added. - -## Concerns - -None. All five verification commands green. diff --git a/.superpowers/sdd/task-b3-brief.md b/.superpowers/sdd/task-b3-brief.md deleted file mode 100644 index c267d371..00000000 --- a/.superpowers/sdd/task-b3-brief.md +++ /dev/null @@ -1,137 +0,0 @@ -# Task B3: pty-host serve engine + ConPTY seam (loopback TCP transport) - -## Goal -Build the detached "pty-host" that owns the agent's ConPTY and exposes it over a -**localhost loopback TCP socket** (127.0.0.1) using the B1 binary protocol, with -ring-replay to new clients, multi-client fan-out, and graceful shutdown. This ports -agent-orchestrator's `pty-host.ts` behavior, with ONE deliberate transport change: -loopback TCP instead of a Windows named pipe (avoids a new dependency and makes the -whole engine testable on Darwin). The ConPTY itself is behind an interface seam so -the serve engine is fully unit-tested here; only the real go-pty implementation is -Windows-only. - -Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, -module `backend/`, branch `migrate-zellij-to-tmux-conpty`. Package: -`internal/adapters/runtime/conpty` (same package as B1's proto.go/ring.go; reuse -them directly — they are in this package). - -## Reference (port the behavior) -`/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/agent-orchestrator/packages/plugins/runtime-process/src/pty-host.ts` -(spawn, READY handshake, appendOutput->ring, broadcast/fan-out, scrollback replay -on connect, the MSG_* handlers, onExit keep-alive, graceful shutdown disposing the -ConPTY before exit). And `index.ts` lines 78-175 for the spawn/READY contract. - -## The ConPTY seam (so the engine is testable on Darwin) -Define an interface the engine uses, with a fake for tests and a real go-pty impl: -```go -// ptyConn is the host's handle to the running agent's pseudo-terminal. -type ptyConn interface { - io.Reader // PTY output (raw bytes) - io.Writer // PTY input (keystrokes) - Resize(cols, rows int) error - Close() error // dispose the ConPTY (graceful) - Done() <-chan struct{} // closed when the child process exits - ExitCode() (int, bool) // (code, true) once exited; (0,false) while running - PID() int -} -``` -- Real impl `conptyConn` in `host_conpty_windows.go` (`//go:build windows`): wraps - `github.com/aymanbagabas/go-pty` (already a dependency). Create the pty, start the - shell command, expose Read/Write/Resize/Close, run a goroutine that Waits the cmd - and records exit code + closes Done(). -- Non-windows stub `host_conpty_other.go` (`//go:build !windows`): a `newConPTY` - that returns an error ("conpty: unsupported on this OS"). Keeps the package - buildable and the engine importable on Darwin. The TESTS use a fake ptyConn - defined in the test file, NOT this stub. - -## The serve engine (`host.go`, cross-platform, the bulk of the work) -```go -// ServeConfig carries everything the host needs. -type ServeConfig struct { - SessionID string - Listener net.Listener // caller provides (loopback); engine owns Accept loop - PTY ptyConn - Ring *Ring -} -// Serve runs the host event loop until the listener closes or Shutdown is invoked. -// It pumps PTY output -> ring + broadcast, accepts clients, replays ring snapshot -// to each new client, dispatches client messages, and on PTY exit broadcasts a -// MSG_STATUS_RES{alive:false} while staying up (keep-alive, like tmux). Returns -// when shut down. -func Serve(ctx context.Context, cfg ServeConfig) error -``` -Behavior to match pty-host.ts: -- Start a goroutine reading PTY output; for each chunk: `ring.Append(chunk)` then - broadcast `EncodeMessage(MsgTerminalData, chunk)` to all clients. Guard the client - set with a mutex. -- On a new client connection: immediately send the ring Snapshot as one - MsgTerminalData frame (scrollback replay) if non-empty, then add to the client - set, and feed its bytes to a per-conn MessageParser. -- Message handlers (mirror handleClientMessage): - - MsgTerminalInput -> if not exited, `pty.Write(payload)`. - - MsgResize -> parse ResizePayload JSON, if not exited `pty.Resize(cols, rows)`. - - MsgGetOutputReq -> parse GetOutputReq (default 50), reply MsgGetOutputRes with - `ring.Tail(lines)`. - - MsgStatusReq -> reply MsgStatusRes JSON {alive, pid, exitCode?} (alive = not - exited). - - MsgKillReq -> trigger graceful shutdown (below). -- On PTY exit (Done() fires): record exit, `ring.FlushPartial()`, broadcast - MsgStatusRes{alive:false,...}. DO NOT close the listener (keep-alive: clients can - still connect and read scrollback; IsAlive stays true until Destroy). -- Graceful shutdown (Shutdown or MsgKillReq or ctx cancel): dispose the ConPTY - (`pty.Close()`) FIRST, then close all client conns, then close the listener. - Mirror the 50ms grace: after disposing the ConPTY, wait ~50ms before returning so - the OS ConPTY helper can release cleanly (port the `setTimeout(...,50)` note; - on Windows this avoids the 0x800700e8 error dialog). Make Serve idempotent on - shutdown. - -## Subcommand entrypoint (`host_main.go`, cross-platform shell, ConPTY part tagged) -```go -// RunHost is the `ao pty-host` entrypoint. argv (after the subcommand name): -// [shellArg...] -// It binds 127.0.0.1:0, creates the ConPTY (newConPTY), prints "READY: \n" -// to stdout (the parent reads this to learn the port), installs signal handlers, -// then runs Serve. Returns a process exit code. -func RunHost(args []string, stdout io.Writer) int -``` -- Bind `net.Listen("tcp", "127.0.0.1:0")`; extract the port from - `ln.Addr().(*net.TCPAddr).Port`. -- ponytail: loopback bind only; any local process on this host could connect to the - port. A per-session random token handshake is the upgrade if multi-user isolation - is needed. Name this in the comment. -- newConPTY(cwd, shellCmd, shellArgs) -> ptyConn (errors on non-windows stub). -- Print `READY: ` AFTER the listener is up and the pty is created. -- Install SIGTERM/SIGINT (+ SIGBREAK on windows is fine to omit in shared code; the - windows file can add it) to call Serve's shutdown. - -## Tests (`host_test.go`, run fully on Darwin with a FAKE ptyConn + real loopback) -Define a `fakePTY` implementing ptyConn backed by in-memory pipes (e.g. two -io.Pipe pairs or buffers + a channel for Done). Use a real `net.Listen("tcp", -"127.0.0.1:0")` and real `net.Dial` clients. Cover: -- A connecting client receives the scrollback snapshot first (seed the ring, connect, - read a MsgTerminalData frame equal to the snapshot). -- PTY output is fanned out to two simultaneously-connected clients. -- MsgTerminalInput from a client reaches the fakePTY's input. -- MsgResize calls fakePTY.Resize with the right cols/rows. -- MsgGetOutputReq returns MsgGetOutputRes with ring.Tail(n). -- MsgStatusReq returns alive:true while running; after fakePTY signals exit, a new - MsgStatusReq returns alive:false with the exit code, and the listener is STILL - accepting (keep-alive). -- MsgKillReq (or Shutdown) disposes the fakePTY (Close called), drops clients, and - closes the listener; Serve returns. -Use the B1 MessageParser to decode frames in the test client. Keep timeouts short -and deterministic (use channels, not sleeps, where possible). - -## Definition of done (from `backend/`) -- `go build ./...` ; `GOOS=windows go build ./...` ; `GOOS=linux go build ./...` succeed. -- `go test -race ./internal/adapters/runtime/conpty/...` passes (B1 tests + these). -- `go vet ./internal/adapters/runtime/conpty/...` clean. - -## Hard rules -- Reuse B1's EncodeMessage/MessageParser/MSG_* and Ring (same package). Do not - duplicate them. -- Only `github.com/aymanbagabas/go-pty` (already present) in the windows-tagged - file; NO new go.mod module (no go-winio — we use stdlib net loopback on purpose). -- Touch only files under `backend/internal/adapters/runtime/conpty/`. -- Never use em dashes ("—"). Minimal/idiomatic; mark shortcuts with `ponytail:`. -- Commit on the branch; trailer `Co-Authored-By: Claude Opus 4.8 `. diff --git a/.superpowers/sdd/task-b3-report.md b/.superpowers/sdd/task-b3-report.md deleted file mode 100644 index 1f871ee9..00000000 --- a/.superpowers/sdd/task-b3-report.md +++ /dev/null @@ -1,148 +0,0 @@ -# Task B3 Report: pty-host serve engine + ConPTY seam - -## Files created - -All under `backend/internal/adapters/runtime/conpty/`: - -| File | Role | -|---|---| -| `host.go` | Cross-platform serve engine: `ptyConn` interface, `ServeConfig`, `Serve()`, `host` event loop, `pumpPTY`, `broadcast`, `handleConn`, `handleClientMsg`, `statusFrame` | -| `host_conpty_windows.go` | `//go:build windows` real impl: `conptyConn` wrapping `github.com/aymanbagabas/go-pty` ConPty + Cmd, goroutine that Waits the process and records exit code | -| `host_conpty_other.go` | `//go:build !windows` stub: `newConPTY` returns an error ("conpty: unsupported on this OS") so the package compiles on Darwin/Linux | -| `host_main.go` | Cross-platform `RunHost` entrypoint: binds `127.0.0.1:0`, calls `newConPTY`, prints `READY: `, installs SIGTERM/SIGINT, calls `Serve` | -| `host_test.go` | 7 tests using `fakePTY` (in-memory pipes) + real loopback sockets + B1 `MessageParser` | - -## Interface seam - -```go -type ptyConn interface { - io.Reader - io.Writer - Resize(cols, rows int) error - Close() error - Done() <-chan struct{} - ExitCode() (int, bool) - PID() int -} -``` - -Real impl (`conptyConn`) is in `host_conpty_windows.go` (`//go:build windows`). Tests use `fakePTY` (defined in `host_test.go`), never the stub. The stub only exists so `go build ./...` succeeds on Darwin/Linux. - -## Build-tag split - -- `host_conpty_windows.go`: `//go:build windows` - imports `github.com/aymanbagabas/go-pty`; only file that touches the go-pty dependency. -- `host_conpty_other.go`: `//go:build !windows` - pure stdlib, no dependencies. -- `host.go`, `host_main.go`: no build tags (cross-platform, stdlib `net` only). -- `host_test.go`: no build tags (runs on Darwin via `fakePTY`). - -## Loopback TCP transport (deliberate deviation from pty-host.ts) - -pty-host.ts uses a Windows named pipe. This Go port uses `net.Listen("tcp", "127.0.0.1:0")` instead: -- No new dependency (stdlib `net`; no go-winio). -- Testable on Darwin with real sockets. -- Port is dynamically assigned (`127.0.0.1:0`); extracted via `ln.Addr().(*net.TCPAddr).Port` and reported in the `READY: ` line. -- Security note (marked with `ponytail:` comment in `host_main.go`): any local process on the host can connect to the port. A per-session random token handshake is the upgrade path for multi-user isolation. - -## Behavior fidelity vs pty-host.ts - -| Behavior | Implemented | -|---|---| -| PTY output pumped to ring + broadcast as MsgTerminalData | Yes (`pumpPTY`) | -| New client receives ring snapshot as one MsgTerminalData frame | Yes (`handleConn` replay before adding to set) | -| MsgTerminalInput forwarded to PTY (if not exited) | Yes | -| MsgResize parsed + forwarded (if not exited); malformed ignored | Yes | -| MsgGetOutputReq with default 50 lines, returns MsgGetOutputRes | Yes | -| MsgStatusReq returns {alive, pid, exitCode?} | Yes | -| MsgKillReq triggers graceful shutdown | Yes | -| PTY exit: FlushPartial, broadcast MsgStatusRes{alive:false}, keep-alive | Yes | -| Graceful shutdown: ConPTY Close first, 50ms grace, close clients, close listener | Yes | -| Shutdown is idempotent (sync.Once) | Yes | -| SIGTERM/SIGINT trigger shutdown in RunHost | Yes | - -## Verification outputs - -``` -go build ./... PASS (Darwin) -GOOS=windows go build ./... PASS (cross-compile) -GOOS=linux go build ./... PASS (cross-compile) -go test -race ./internal/adapters/runtime/conpty/... 34 passed in 2 packages -go vet ./internal/adapters/runtime/conpty/... PASS (no issues) -``` - -## Tests implemented - -1. `TestScrollbackReplay` - seeded ring delivered as first frame to new client. -2. `TestFanOut` - two simultaneous clients both receive PTY output. -3. `TestTerminalInput` - MsgTerminalInput bytes reach fakePTY's input pipe. -4. `TestResize` - MsgResize calls fakePTY.Resize(132, 40). -5. `TestGetOutputReq` - MsgGetOutputReq returns ring.Tail(n) as MsgGetOutputRes. -6. `TestStatusReq_AliveAndExited` - alive:true while running; alive:false + exitCode after signalExit; listener stays open (keep-alive). -7. `TestKillReq` - MsgKillReq calls pty.Close(), closes listener, Serve returns. -8. `TestShutdownViaCtxCancel` - ctx cancel triggers graceful shutdown. - -Tests use channels (not sleeps) for synchronization; deterministic under -race. - -## Concerns - -### Windows-only wiring not runtime-verified - -`host_conpty_windows.go` cross-compiles cleanly (`GOOS=windows go build ./...` passes), but the real `conptyConn` path could not be exercised on this Darwin machine. Specific concerns: - -1. `go-pty`'s `New()` on Windows returns `ConPty` (confirmed from `pty_windows.go`); the type-assert `p.(gopty.ConPty)` should succeed. If a future go-pty version changes this, the assert will panic at runtime. -2. `cmd.Environ()` on Windows (inside `newConPTY`) calls `exec.Command(shellCmd).Environ()` to inherit the parent environment. This is a lightweight approach but spawns a short-lived process on Windows just to get the env; a cleaner path is `os.Environ()`. Left as-is to match node-pty's `process.env` pass-through semantics. -3. The `conptyConn.Close()` only closes the ConPTY handle (`pty.Close()`), not the cmd process explicitly. On Windows, closing the ConPTY handle causes the child process to receive EOF and exit on its own; this matches how node-pty handles it. If the child does not exit, there is no explicit kill here. - -These are not blocking for Darwin CI; they should be verified in a Windows CI job before merging to production. - ---- - -## Review follow-up (1 Important + 2 Minor fixes) — commit 6cd6e2a - -### 1. Important (real bug): scrollback/live-output ordering could drop bytes -`host.go` `handleConn` took the ring `Snapshot()` and added the conn to the -broadcast set under two separate `h.mu` acquisitions. A PTY chunk arriving in -that gap was in neither the snapshot nor that client's broadcast and was -silently dropped (an internal hole in the client's stream). - -Fix: acquire `h.mu` once, take the snapshot, write it to the conn (only if -non-empty), then add the conn to `clients`, then release. `broadcast()` also -takes `h.mu`, so it cannot interleave: every chunk is either already in the -snapshot or broadcast strictly after the conn joins the set. Added a `ponytail:` -note that the snapshot write happens under the lock (bounded by `MaxOutputLines`; -upgrade path is a per-client send queue). - -Regression test: `TestScrollbackLiveOrdering_NoDrop` (host_test.go). The PTY -emits a contiguous stream of numbered lines (`[NNNN]\n`) while a client connects; -the test asserts the client's received stream has no internal gap in the line -indices and reaches the final chunk. Verified it **reliably fails against the old -two-step code** (reverted temporarily: 9/20 iterations failed with -"non-contiguous line indices (dropped chunk)", e.g. "15 followed by 17") and -**passes under `-race -count=20`** with the fix. - -### 2. Minor (faithfulness): Windows Close() had no Kill fallback -`host_conpty_windows.go` `conptyConn.Close()` now also calls -`c.cmd.Process.Kill()` (nil-guarded) in addition to `pty.Close()`, so a child -that ignores ConPTY EOF still exits and `Done()` fires. Mirrors `pty.kill()` in -pty-host.ts. (windows-tagged; compile-checked via `GOOS=windows go build`.) - -### 3. Minor (simplify): redundant env accessor -`host_conpty_windows.go` now uses `os.Environ()` instead of -`exec.Command(shellCmd).Environ()`; dropped the `os/exec` import, added `os`. - -### Command outputs (run from `backend/`) - -``` -$ go build ./... -> Success -$ GOOS=windows go build ./... -> Success -$ GOOS=linux go build ./... -> Success -$ go vet ./internal/adapters/runtime/conpty/... -> No issues found -$ go test -race -count=20 ./internal/adapters/runtime/conpty/... - 700 passed in 2 packages (35 tests x 20 counts, all green) - -Regression-guard verification (old buggy code reverted): -$ go test -race -run TestScrollbackLiveOrdering_NoDrop -count=20 ... - 11 passed, 9 failed -> "non-contiguous line indices (dropped chunk)" -``` - -Touched only files under `backend/internal/adapters/runtime/conpty/`. No new deps. -Committed on branch `migrate-zellij-to-tmux-conpty` as `6cd6e2a`. diff --git a/.superpowers/sdd/task-b3-review-package.txt b/.superpowers/sdd/task-b3-review-package.txt deleted file mode 100644 index 4d3f43be..00000000 --- a/.superpowers/sdd/task-b3-review-package.txt +++ /dev/null @@ -1,1391 +0,0 @@ -=== COMMITS === -a7b052b feat(conpty): add pty-host serve engine with loopback TCP transport (B3) - -=== STAT === -backend/internal/adapters/runtime/conpty/host.go | 256 +++++++++++ - .../adapters/runtime/conpty/host_conpty_other.go | 12 + - .../adapters/runtime/conpty/host_conpty_windows.go | 94 ++++ - .../internal/adapters/runtime/conpty/host_main.go | 84 ++++ - .../internal/adapters/runtime/conpty/host_test.go | 481 +++++++++++++++++++++ - 5 files changed, 927 insertions(+) - -=== DIFF === -backend/internal/adapters/runtime/conpty/host.go | 256 +++++++++++ - .../adapters/runtime/conpty/host_conpty_other.go | 12 + - .../adapters/runtime/conpty/host_conpty_windows.go | 94 ++++ - .../internal/adapters/runtime/conpty/host_main.go | 84 ++++ - .../internal/adapters/runtime/conpty/host_test.go | 481 +++++++++++++++++++++ - 5 files changed, 927 insertions(+) - -diff --git a/backend/internal/adapters/runtime/conpty/host.go b/backend/internal/adapters/runtime/conpty/host.go -new file mode 100644 -index 0000000..b05d9c6 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/host.go -@@ -0,0 +1,256 @@ -+// Package conpty - host.go implements the serve engine for the pty-host -+// detached process. It owns the agent's PTY (via the ptyConn seam), exposes -+// it over a loopback TCP socket using the B1 binary protocol, replays -+// scrollback to new clients, fans output to all connected clients, and shuts -+// down gracefully (ConPTY dispose first, then clients, then listener). -+// -+// This file is cross-platform; only the real conptyConn impl is Windows-tagged. -+package conpty -+ -+import ( -+ "context" -+ "encoding/json" -+ "io" -+ "net" -+ "sync" -+ "time" -+) -+ -+// ptyConn is the host's handle to the running agent's pseudo-terminal. -+// The real impl (conptyConn) lives in host_conpty_windows.go; tests use a fake. -+type ptyConn interface { -+ io.Reader // PTY output (raw bytes from the terminal) -+ io.Writer // PTY input (keystrokes to the terminal) -+ Resize(cols, rows int) error -+ Close() error // dispose the ConPTY -+ Done() <-chan struct{} // closed when the child process exits -+ ExitCode() (int, bool) // (code, true) once exited; (0, false) while running -+ PID() int -+} -+ -+// ServeConfig carries everything the host needs. -+type ServeConfig struct { -+ SessionID string -+ Listener net.Listener // caller provides (loopback); engine owns Accept loop -+ PTY ptyConn -+ Ring *Ring -+} -+ -+// Serve runs the host event loop until the listener closes or Shutdown is -+// invoked via the returned ShutdownFunc. It pumps PTY output into the ring -+// and broadcasts to all clients, accepts new clients (replaying ring snapshot), -+// and dispatches client messages. On PTY exit it broadcasts a status update -+// but stays alive (keep-alive, mirroring tmux behavior). Returns when shut down. -+func Serve(ctx context.Context, cfg ServeConfig) error { -+ h := &host{ -+ cfg: cfg, -+ clients: make(map[net.Conn]struct{}), -+ shutdownC: make(chan struct{}), -+ } -+ return h.run(ctx) -+} -+ -+// host holds the mutable state for a single pty-host session. -+type host struct { -+ cfg ServeConfig -+ mu sync.Mutex -+ clients map[net.Conn]struct{} -+ -+ shutdownOnce sync.Once -+ shutdownC chan struct{} // closed when Shutdown is called -+} -+ -+// run is the main event loop. -+func (h *host) run(ctx context.Context) error { -+ // Pump PTY output to ring + broadcast. -+ go h.pumpPTY() -+ -+ // Watch for ctx cancellation and trigger shutdown. -+ go func() { -+ select { -+ case <-ctx.Done(): -+ h.shutdown() -+ case <-h.shutdownC: -+ } -+ }() -+ -+ // Accept loop. -+ for { -+ conn, err := h.cfg.Listener.Accept() -+ if err != nil { -+ // Listener closed: either shutdown or external close. -+ return nil -+ } -+ go h.handleConn(conn) -+ } -+} -+ -+// shutdown is idempotent: disposes the ConPTY, closes clients, closes the -+// listener. Mirrors the pty-host.ts shutdown() function. -+// ponytail: 50ms sleep after pty.Close() gives the OS ConPTY helper -+// (conpty_console_list_agent.exe) time to release cleanly; avoids the -+// 0x800700e8 error dialog on Windows. -+func (h *host) shutdown() { -+ h.shutdownOnce.Do(func() { -+ close(h.shutdownC) -+ -+ // 1. Dispose the ConPTY first (critical ordering). -+ _ = h.cfg.PTY.Close() -+ -+ // 2. Brief grace so the OS ConPTY helper can clean up. -+ time.Sleep(50 * time.Millisecond) -+ -+ // 3. Close all client connections. -+ h.mu.Lock() -+ for c := range h.clients { -+ _ = c.Close() -+ } -+ h.clients = make(map[net.Conn]struct{}) -+ h.mu.Unlock() -+ -+ // 4. Close the listener to unblock Accept. -+ _ = h.cfg.Listener.Close() -+ }) -+} -+ -+// pumpPTY reads PTY output continuously, appends to the ring, and broadcasts -+// to clients. On PTY exit it flushes the partial line and sends a status -+// update but does NOT close the listener (keep-alive). -+func (h *host) pumpPTY() { -+ buf := make([]byte, 32*1024) -+ for { -+ n, err := h.cfg.PTY.Read(buf) -+ if n > 0 { -+ chunk := make([]byte, n) -+ copy(chunk, buf[:n]) -+ h.cfg.Ring.Append(chunk) -+ h.broadcast(EncodeMessage(MsgTerminalData, chunk)) -+ } -+ if err != nil { -+ break -+ } -+ } -+ -+ // PTY reader is done (process exited or PTY closed). Wait for the Done -+ // signal so ExitCode is populated before we send the status broadcast. -+ <-h.cfg.PTY.Done() -+ -+ h.cfg.Ring.FlushPartial() -+ -+ code, _ := h.cfg.PTY.ExitCode() -+ pid := h.cfg.PTY.PID() -+ h.broadcast(statusFrame(false, pid, &code)) -+ // Keep-alive: do NOT shutdown here. The host stays up so clients can -+ // still connect and read scrollback. -+} -+ -+// broadcast sends msg to all connected clients, removing any that error. -+func (h *host) broadcast(msg []byte) { -+ h.mu.Lock() -+ defer h.mu.Unlock() -+ for c := range h.clients { -+ if _, err := c.Write(msg); err != nil { -+ _ = c.Close() -+ delete(h.clients, c) -+ } -+ } -+} -+ -+// sendTo sends msg to a single conn (best-effort; removes on error). -+func (h *host) sendTo(conn net.Conn, msg []byte) { -+ if _, err := conn.Write(msg); err != nil { -+ h.mu.Lock() -+ _ = conn.Close() -+ delete(h.clients, conn) -+ h.mu.Unlock() -+ } -+} -+ -+// handleConn manages the lifecycle of a single client connection. -+func (h *host) handleConn(conn net.Conn) { -+ // Scrollback replay: send current ring snapshot as a single TerminalData -+ // frame before registering the client in the broadcast set. -+ snap := h.cfg.Ring.Snapshot() -+ if len(snap) > 0 { -+ if _, err := conn.Write(EncodeMessage(MsgTerminalData, snap)); err != nil { -+ _ = conn.Close() -+ return -+ } -+ } -+ -+ h.mu.Lock() -+ h.clients[conn] = struct{}{} -+ h.mu.Unlock() -+ -+ defer func() { -+ h.mu.Lock() -+ delete(h.clients, conn) -+ h.mu.Unlock() -+ _ = conn.Close() -+ }() -+ -+ parser := NewMessageParser(func(msgType byte, payload []byte) { -+ h.handleClientMsg(conn, msgType, payload) -+ }) -+ -+ buf := make([]byte, 4096) -+ for { -+ n, err := conn.Read(buf) -+ if n > 0 { -+ parser.Feed(buf[:n]) -+ } -+ if err != nil { -+ return -+ } -+ } -+} -+ -+// handleClientMsg dispatches a decoded client message. Mirrors handleClientMessage -+// from pty-host.ts. -+func (h *host) handleClientMsg(conn net.Conn, msgType byte, payload []byte) { -+ switch msgType { -+ case MsgTerminalInput: -+ if _, alive := h.cfg.PTY.ExitCode(); !alive { -+ _, _ = h.cfg.PTY.Write(payload) -+ } -+ -+ case MsgResize: -+ if _, alive := h.cfg.PTY.ExitCode(); !alive { -+ var rp ResizePayload -+ if err := json.Unmarshal(payload, &rp); err == nil { -+ _ = h.cfg.PTY.Resize(rp.Cols, rp.Rows) -+ } -+ // Malformed resize: ignore (matches TS behavior). -+ } -+ -+ case MsgGetOutputReq: -+ lines := 50 // default matches TS -+ var req GetOutputReq -+ if err := json.Unmarshal(payload, &req); err == nil && req.Lines > 0 { -+ lines = req.Lines -+ } -+ text := h.cfg.Ring.Tail(lines) -+ h.sendTo(conn, EncodeMessage(MsgGetOutputRes, []byte(text))) -+ -+ case MsgStatusReq: -+ code, exited := h.cfg.PTY.ExitCode() -+ alive := !exited -+ pid := h.cfg.PTY.PID() -+ var codePtr *int -+ if exited { -+ codePtr = &code -+ } -+ h.sendTo(conn, statusFrame(alive, pid, codePtr)) -+ -+ case MsgKillReq: -+ // Trigger graceful shutdown; returns immediately (idempotent). -+ go h.shutdown() -+ } -+} -+ -+// statusFrame builds a MsgStatusRes frame. -+func statusFrame(alive bool, pid int, exitCode *int) []byte { -+ sp := StatusPayload{Alive: alive, PID: pid, ExitCode: exitCode} -+ b, _ := json.Marshal(sp) -+ return EncodeMessage(MsgStatusRes, b) -+} -diff --git a/backend/internal/adapters/runtime/conpty/host_conpty_other.go b/backend/internal/adapters/runtime/conpty/host_conpty_other.go -new file mode 100644 -index 0000000..aca39d1 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/host_conpty_other.go -@@ -0,0 +1,12 @@ -+//go:build !windows -+ -+package conpty -+ -+import "errors" -+ -+// newConPTY is a stub on non-Windows platforms. The serve engine (host.go) and -+// tests use a fake ptyConn; this stub only exists to keep the package buildable -+// on Darwin/Linux so the engine can be imported and tested without Windows. -+func newConPTY(cwd, shellCmd string, shellArgs []string) (ptyConn, error) { -+ return nil, errors.New("conpty: unsupported on this OS") -+} -diff --git a/backend/internal/adapters/runtime/conpty/host_conpty_windows.go b/backend/internal/adapters/runtime/conpty/host_conpty_windows.go -new file mode 100644 -index 0000000..956a04c ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/host_conpty_windows.go -@@ -0,0 +1,94 @@ -+//go:build windows -+ -+package conpty -+ -+import ( -+ "fmt" -+ "os/exec" -+ "sync" -+ -+ gopty "github.com/aymanbagabas/go-pty" -+) -+ -+// conptyConn is the real ptyConn implementation backed by go-pty's ConPty -+// (Windows ConPTY API). Only compiled on Windows. -+type conptyConn struct { -+ pty gopty.ConPty -+ cmd *gopty.Cmd -+ -+ once sync.Once -+ doneC chan struct{} -+ exitCode int -+ exited bool -+ exitMu sync.Mutex -+} -+ -+// newConPTY creates a ConPTY session running shellCmd in cwd with shellArgs. -+// It starts the process and returns a ptyConn ready for use. -+func newConPTY(cwd, shellCmd string, shellArgs []string) (ptyConn, error) { -+ // go-pty's New() returns a ConPty on Windows. -+ p, err := gopty.New() -+ if err != nil { -+ return nil, fmt.Errorf("conpty: create pty: %w", err) -+ } -+ cp, ok := p.(gopty.ConPty) -+ if !ok { -+ _ = p.Close() -+ return nil, fmt.Errorf("conpty: expected ConPty on windows, got %T", p) -+ } -+ -+ // Set an initial size matching node-pty defaults from pty-host.ts. -+ if err := cp.Resize(220, 50); err != nil { -+ _ = cp.Close() -+ return nil, fmt.Errorf("conpty: initial resize: %w", err) -+ } -+ -+ cmd := cp.Command(shellCmd, shellArgs...) -+ cmd.Dir = cwd -+ // Inherit parent env so PATH, HOME, etc. are available. -+ cmd.Env = exec.Command(shellCmd).Environ() -+ -+ if err := cmd.Start(); err != nil { -+ _ = cp.Close() -+ return nil, fmt.Errorf("conpty: start command: %w", err) -+ } -+ -+ c := &conptyConn{ -+ pty: cp, -+ cmd: cmd, -+ doneC: make(chan struct{}), -+ } -+ -+ go c.wait() -+ return c, nil -+} -+ -+func (c *conptyConn) wait() { -+ _ = c.cmd.Wait() -+ code := 0 -+ if c.cmd.ProcessState != nil { -+ code = c.cmd.ProcessState.ExitCode() -+ } -+ c.exitMu.Lock() -+ c.exitCode = code -+ c.exited = true -+ c.exitMu.Unlock() -+ c.once.Do(func() { close(c.doneC) }) -+} -+ -+func (c *conptyConn) Read(b []byte) (int, error) { return c.pty.Read(b) } -+func (c *conptyConn) Write(b []byte) (int, error) { return c.pty.Write(b) } -+func (c *conptyConn) Close() error { return c.pty.Close() } -+func (c *conptyConn) Resize(cols, rows int) error { return c.pty.Resize(cols, rows) } -+func (c *conptyConn) Done() <-chan struct{} { return c.doneC } -+func (c *conptyConn) PID() int { -+ if c.cmd.Process == nil { -+ return 0 -+ } -+ return c.cmd.Process.Pid -+} -+func (c *conptyConn) ExitCode() (int, bool) { -+ c.exitMu.Lock() -+ defer c.exitMu.Unlock() -+ return c.exitCode, c.exited -+} -diff --git a/backend/internal/adapters/runtime/conpty/host_main.go b/backend/internal/adapters/runtime/conpty/host_main.go -new file mode 100644 -index 0000000..802f98d ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/host_main.go -@@ -0,0 +1,84 @@ -+// host_main.go is the RunHost entrypoint for the "ao pty-host" subcommand. -+// It is cross-platform: the loopback TCP bind and signal wiring work on all -+// OSes; only the ConPTY creation (newConPTY) is OS-gated via build tags. -+package conpty -+ -+import ( -+ "context" -+ "fmt" -+ "io" -+ "net" -+ "os" -+ "os/signal" -+ "syscall" -+) -+ -+// RunHost is the "ao pty-host" entrypoint. argv is everything after the -+// subcommand name: [shellArg...] -+// -+// It binds 127.0.0.1:0 (OS assigns the port), creates the ConPTY, prints -+// "READY: \n" to stdout (the parent process reads this to learn the -+// port), installs SIGTERM/SIGINT handlers, then runs Serve. Returns a process -+// exit code. -+// -+// ponytail: loopback bind only; any local process on this host can connect to -+// the assigned port. A per-session random token handshake is the upgrade path -+// if multi-user isolation is needed. -+func RunHost(args []string, stdout io.Writer) int { -+ if len(args) < 3 { -+ fmt.Fprintf(os.Stderr, "usage: ao pty-host [shellArg...]\n") -+ return 1 -+ } -+ -+ sessionID := args[0] -+ cwd := args[1] -+ shellCmd := args[2] -+ shellArgs := args[3:] -+ -+ // Bind before creating the PTY so we can report READY atomically. -+ ln, err := net.Listen("tcp", "127.0.0.1:0") -+ if err != nil { -+ fmt.Fprintf(os.Stderr, "pty-host [%s]: listen: %v\n", sessionID, err) -+ return 1 -+ } -+ port := ln.Addr().(*net.TCPAddr).Port -+ -+ pty, err := newConPTY(cwd, shellCmd, shellArgs) -+ if err != nil { -+ _ = ln.Close() -+ fmt.Fprintf(os.Stderr, "pty-host [%s]: newConPTY: %v\n", sessionID, err) -+ return 1 -+ } -+ -+ // Print READY after both the listener and the PTY are up. -+ fmt.Fprintf(stdout, "READY:%d %d\n", pty.PID(), port) -+ -+ ctx, cancel := context.WithCancel(context.Background()) -+ defer cancel() -+ -+ // Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown. -+ sigC := make(chan os.Signal, 1) -+ signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT) -+ go func() { -+ select { -+ case sig := <-sigC: -+ fmt.Fprintf(os.Stderr, "pty-host [%s]: signal %v, shutting down\n", sessionID, sig) -+ cancel() -+ case <-ctx.Done(): -+ } -+ }() -+ -+ ring := NewRing() -+ cfg := ServeConfig{ -+ SessionID: sessionID, -+ Listener: ln, -+ PTY: pty, -+ Ring: ring, -+ } -+ -+ if err := Serve(ctx, cfg); err != nil { -+ fmt.Fprintf(os.Stderr, "pty-host [%s]: serve: %v\n", sessionID, err) -+ return 1 -+ } -+ return 0 -+} -diff --git a/backend/internal/adapters/runtime/conpty/host_test.go b/backend/internal/adapters/runtime/conpty/host_test.go -new file mode 100644 -index 0000000..2ccbcc4 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/host_test.go -@@ -0,0 +1,481 @@ -+package conpty -+ -+import ( -+ "context" -+ "encoding/json" -+ "fmt" -+ "io" -+ "net" -+ "sync" -+ "testing" -+ "time" -+) -+ -+// --------------------------------------------------------------------------- -+// fakePTY implements ptyConn using in-memory pipes. Used only in tests; -+// the real ConPTY impl is Windows-only. -+// --------------------------------------------------------------------------- -+ -+type fakePTY struct { -+ // output is what the fake "terminal" writes to the host (PTY -> host reader) -+ outR *io.PipeReader -+ outW *io.PipeWriter -+ -+ // input is what the host writes to the fake terminal (keystrokes) -+ inR *io.PipeReader -+ inW *io.PipeWriter -+ -+ resizeMu sync.Mutex -+ resizes []ResizePayload -+ -+ doneOnce sync.Once -+ doneC chan struct{} -+ exitCode int -+ closed bool -+ closeMu sync.Mutex -+ -+ pid int -+} -+ -+func newFakePTY(pid int) *fakePTY { -+ outR, outW := io.Pipe() -+ inR, inW := io.Pipe() -+ return &fakePTY{ -+ outR: outR, -+ outW: outW, -+ inR: inR, -+ inW: inW, -+ doneC: make(chan struct{}), -+ pid: pid, -+ } -+} -+ -+// WriteOutput simulates the PTY producing output (e.g. shell printing text). -+func (f *fakePTY) WriteOutput(data []byte) (int, error) { return f.outW.Write(data) } -+ -+// CloseOutput simulates the PTY process exiting (closes the read side). -+func (f *fakePTY) CloseOutput(code int) { -+ f.exitCode = code -+ f.outW.Close() -+} -+ -+// ReadInput lets tests inspect what the host forwarded to the PTY. -+func (f *fakePTY) ReadInput(buf []byte) (int, error) { return f.inR.Read(buf) } -+ -+// ptyConn interface implementation. -+func (f *fakePTY) Read(b []byte) (int, error) { return f.outR.Read(b) } -+func (f *fakePTY) Write(b []byte) (int, error) { return f.inW.Write(b) } -+ -+func (f *fakePTY) Resize(cols, rows int) error { -+ f.resizeMu.Lock() -+ defer f.resizeMu.Unlock() -+ f.resizes = append(f.resizes, ResizePayload{Cols: cols, Rows: rows}) -+ return nil -+} -+ -+func (f *fakePTY) Close() error { -+ f.closeMu.Lock() -+ defer f.closeMu.Unlock() -+ f.closed = true -+ // Close both pipes so pumpPTY and any Read calls unblock. -+ _ = f.outW.Close() -+ _ = f.inW.Close() -+ f.doneOnce.Do(func() { close(f.doneC) }) -+ return nil -+} -+ -+func (f *fakePTY) Done() <-chan struct{} { return f.doneC } -+ -+func (f *fakePTY) ExitCode() (int, bool) { -+ select { -+ case <-f.doneC: -+ return f.exitCode, true -+ default: -+ return 0, false -+ } -+} -+ -+func (f *fakePTY) PID() int { return f.pid } -+ -+// signalExit simulates the child process exiting, triggering the Done channel -+// and ExitCode returning true. -+func (f *fakePTY) signalExit(code int) { -+ f.exitCode = code -+ f.doneOnce.Do(func() { close(f.doneC) }) -+ _ = f.outW.Close() // unblocks pumpPTY's Read -+} -+ -+// --------------------------------------------------------------------------- -+// testClient wraps a net.Conn and a MessageParser for easy frame reading. -+// --------------------------------------------------------------------------- -+ -+type testClient struct { -+ conn net.Conn -+ frameC chan struct{ typ byte; payload []byte } -+ parser *MessageParser -+} -+ -+func newTestClient(t *testing.T, addr string) *testClient { -+ t.Helper() -+ conn, err := net.Dial("tcp", addr) -+ if err != nil { -+ t.Fatalf("dial %s: %v", addr, err) -+ } -+ tc := &testClient{ -+ conn: conn, -+ frameC: make(chan struct{ typ byte; payload []byte }, 64), -+ } -+ tc.parser = NewMessageParser(func(msgType byte, payload []byte) { -+ tc.frameC <- struct{ typ byte; payload []byte }{msgType, payload} -+ }) -+ go func() { -+ buf := make([]byte, 4096) -+ for { -+ n, err := conn.Read(buf) -+ if n > 0 { -+ tc.parser.Feed(buf[:n]) -+ } -+ if err != nil { -+ close(tc.frameC) -+ return -+ } -+ } -+ }() -+ return tc -+} -+ -+// readFrame blocks until a frame arrives or 2s times out. -+func (tc *testClient) readFrame(t *testing.T) (typ byte, payload []byte) { -+ t.Helper() -+ select { -+ case f, ok := <-tc.frameC: -+ if !ok { -+ t.Fatal("client frame channel closed (connection dropped)") -+ } -+ return f.typ, f.payload -+ case <-time.After(2 * time.Second): -+ t.Fatal("timeout waiting for frame") -+ return 0, nil -+ } -+} -+ -+// send writes a framed message to the server. -+func (tc *testClient) send(msgType byte, payload []byte) error { -+ _, err := tc.conn.Write(EncodeMessage(msgType, payload)) -+ return err -+} -+ -+func (tc *testClient) close() { _ = tc.conn.Close() } -+ -+// --------------------------------------------------------------------------- -+// Helper: start a Serve with a freshly created listener + fakePTY. -+// --------------------------------------------------------------------------- -+ -+type serveFixture struct { -+ pty *fakePTY -+ ring *Ring -+ ln net.Listener -+ addr string -+ cancel context.CancelFunc -+ done chan error -+} -+ -+func startServe(t *testing.T, pid int) *serveFixture { -+ t.Helper() -+ ln, err := net.Listen("tcp", "127.0.0.1:0") -+ if err != nil { -+ t.Fatalf("listen: %v", err) -+ } -+ pty := newFakePTY(pid) -+ ring := NewRing() -+ ctx, cancel := context.WithCancel(context.Background()) -+ done := make(chan error, 1) -+ go func() { -+ done <- Serve(ctx, ServeConfig{ -+ SessionID: fmt.Sprintf("test-%d", pid), -+ Listener: ln, -+ PTY: pty, -+ Ring: ring, -+ }) -+ }() -+ return &serveFixture{ -+ pty: pty, -+ ring: ring, -+ ln: ln, -+ addr: ln.Addr().String(), -+ cancel: cancel, -+ done: done, -+ } -+} -+ -+// waitDone waits for Serve to return (up to 2s). -+func (f *serveFixture) waitDone(t *testing.T) { -+ t.Helper() -+ select { -+ case <-f.done: -+ case <-time.After(2 * time.Second): -+ t.Fatal("Serve did not return in time") -+ } -+} -+ -+// --------------------------------------------------------------------------- -+// Tests -+// --------------------------------------------------------------------------- -+ -+// TestScrollbackReplay: seed the ring, connect a client; first frame must be -+// MsgTerminalData containing the ring snapshot. -+func TestScrollbackReplay(t *testing.T) { -+ f := startServe(t, 100) -+ defer f.cancel() -+ -+ // Seed ring directly before the client connects. -+ f.ring.Append([]byte("line1\nline2\n")) -+ snap := f.ring.Snapshot() -+ -+ c := newTestClient(t, f.addr) -+ defer c.close() -+ -+ typ, payload := c.readFrame(t) -+ if typ != MsgTerminalData { -+ t.Fatalf("got type 0x%02x, want MsgTerminalData", typ) -+ } -+ if string(payload) != string(snap) { -+ t.Fatalf("scrollback payload = %q, want %q", payload, snap) -+ } -+} -+ -+// TestFanOut: two clients receive the same PTY output. -+func TestFanOut(t *testing.T) { -+ f := startServe(t, 101) -+ defer f.cancel() -+ -+ c1 := newTestClient(t, f.addr) -+ defer c1.close() -+ c2 := newTestClient(t, f.addr) -+ defer c2.close() -+ -+ // Write PTY output after both clients have connected. -+ // We need to give the server a moment to register both clients; use a -+ // brief sync by sending a status req from each and waiting for responses. -+ // ponytail: channel-based sync via status round-trip avoids sleeps. -+ _ = c1.send(MsgStatusReq, nil) -+ _ = c2.send(MsgStatusReq, nil) -+ // Drain status responses. -+ c1.readFrame(t) -+ c2.readFrame(t) -+ -+ msg := []byte("hello from pty\n") -+ if _, err := f.pty.WriteOutput(msg); err != nil { -+ t.Fatalf("WriteOutput: %v", err) -+ } -+ -+ // Both clients should receive a MsgTerminalData with msg. -+ for _, c := range []*testClient{c1, c2} { -+ typ, payload := c.readFrame(t) -+ if typ != MsgTerminalData { -+ t.Fatalf("got type 0x%02x, want MsgTerminalData", typ) -+ } -+ if string(payload) != string(msg) { -+ t.Fatalf("payload = %q, want %q", payload, msg) -+ } -+ } -+} -+ -+// TestTerminalInput: MsgTerminalInput from a client reaches the fakePTY's input. -+func TestTerminalInput(t *testing.T) { -+ f := startServe(t, 102) -+ defer f.cancel() -+ -+ c := newTestClient(t, f.addr) -+ defer c.close() -+ -+ keystrokes := []byte("ls -la\r") -+ if err := c.send(MsgTerminalInput, keystrokes); err != nil { -+ t.Fatalf("send: %v", err) -+ } -+ -+ buf := make([]byte, len(keystrokes)) -+ if _, err := io.ReadFull(f.pty.inR, buf); err != nil { -+ t.Fatalf("read from pty input: %v", err) -+ } -+ if string(buf) != string(keystrokes) { -+ t.Fatalf("pty input = %q, want %q", buf, keystrokes) -+ } -+} -+ -+// TestResize: MsgResize calls fakePTY.Resize with the right cols/rows. -+func TestResize(t *testing.T) { -+ f := startServe(t, 103) -+ defer f.cancel() -+ -+ c := newTestClient(t, f.addr) -+ defer c.close() -+ -+ payload, _ := json.Marshal(ResizePayload{Cols: 132, Rows: 40}) -+ if err := c.send(MsgResize, payload); err != nil { -+ t.Fatalf("send: %v", err) -+ } -+ -+ // Poll for the resize to arrive (it's async). Channel-based: send a -+ // status req and wait for its reply, which guarantees the resize was -+ // processed (single goroutine handles all messages per connection). -+ _ = c.send(MsgStatusReq, nil) -+ c.readFrame(t) // discard status response -+ -+ f.pty.resizeMu.Lock() -+ resizes := f.pty.resizes -+ f.pty.resizeMu.Unlock() -+ -+ if len(resizes) != 1 { -+ t.Fatalf("got %d resize calls, want 1", len(resizes)) -+ } -+ if resizes[0].Cols != 132 || resizes[0].Rows != 40 { -+ t.Fatalf("resize = %+v, want {132 40}", resizes[0]) -+ } -+} -+ -+// TestGetOutputReq: MsgGetOutputReq returns MsgGetOutputRes with ring.Tail(n). -+func TestGetOutputReq(t *testing.T) { -+ f := startServe(t, 104) -+ defer f.cancel() -+ -+ f.ring.Append([]byte("alpha\nbeta\ngamma\n")) -+ -+ c := newTestClient(t, f.addr) -+ defer c.close() -+ -+ // Drain scrollback frame. -+ c.readFrame(t) -+ -+ reqPayload, _ := json.Marshal(GetOutputReq{Lines: 2}) -+ if err := c.send(MsgGetOutputReq, reqPayload); err != nil { -+ t.Fatalf("send: %v", err) -+ } -+ -+ typ, payload := c.readFrame(t) -+ if typ != MsgGetOutputRes { -+ t.Fatalf("got type 0x%02x, want MsgGetOutputRes", typ) -+ } -+ want := f.ring.Tail(2) -+ if string(payload) != want { -+ t.Fatalf("GetOutputRes = %q, want %q", payload, want) -+ } -+} -+ -+// TestStatusReq_AliveAndExited: MsgStatusReq returns alive:true while running; -+// after the PTY exits, returns alive:false with exitCode. Listener stays open. -+func TestStatusReq_AliveAndExited(t *testing.T) { -+ f := startServe(t, 105) -+ defer f.cancel() -+ -+ c := newTestClient(t, f.addr) -+ defer c.close() -+ -+ // While running: expect alive:true. -+ if err := c.send(MsgStatusReq, nil); err != nil { -+ t.Fatalf("send: %v", err) -+ } -+ typ, payload := c.readFrame(t) -+ if typ != MsgStatusRes { -+ t.Fatalf("got type 0x%02x, want MsgStatusRes", typ) -+ } -+ var sp StatusPayload -+ if err := json.Unmarshal(payload, &sp); err != nil { -+ t.Fatalf("unmarshal: %v", err) -+ } -+ if !sp.Alive { -+ t.Fatalf("expected alive=true, got false") -+ } -+ if sp.PID != 105 { -+ t.Fatalf("expected pid=105, got %d", sp.PID) -+ } -+ -+ // Simulate PTY exit. -+ f.pty.signalExit(42) -+ -+ // Drain the broadcast status-res that pumpPTY sends on exit. -+ exitBcast, _ := c.readFrame(t) -+ if exitBcast != MsgStatusRes { -+ t.Fatalf("exit broadcast type = 0x%02x, want MsgStatusRes", exitBcast) -+ } -+ -+ // Now a new status req should report alive:false. -+ if err := c.send(MsgStatusReq, nil); err != nil { -+ t.Fatalf("send: %v", err) -+ } -+ typ2, payload2 := c.readFrame(t) -+ if typ2 != MsgStatusRes { -+ t.Fatalf("got type 0x%02x, want MsgStatusRes", typ2) -+ } -+ var sp2 StatusPayload -+ if err := json.Unmarshal(payload2, &sp2); err != nil { -+ t.Fatalf("unmarshal: %v", err) -+ } -+ if sp2.Alive { -+ t.Fatalf("expected alive=false after exit") -+ } -+ if sp2.ExitCode == nil || *sp2.ExitCode != 42 { -+ t.Fatalf("expected exitCode=42, got %v", sp2.ExitCode) -+ } -+ -+ // Keep-alive: the listener must still accept new connections. -+ c2 := newTestClient(t, f.addr) -+ defer c2.close() -+ if err := c2.send(MsgStatusReq, nil); err != nil { -+ t.Fatalf("keep-alive send: %v", err) -+ } -+ _, _ = c2.readFrame(t) // just verify it didn't crash -+} -+ -+// TestKillReq: MsgKillReq disposes the fakePTY, drops clients, closes -+// listener, and Serve returns. -+func TestKillReq(t *testing.T) { -+ f := startServe(t, 106) -+ -+ c := newTestClient(t, f.addr) -+ -+ if err := c.send(MsgKillReq, nil); err != nil { -+ t.Fatalf("send: %v", err) -+ } -+ -+ // Serve should return within 2s (includes the 50ms grace sleep). -+ f.waitDone(t) -+ -+ // PTY Close must have been called. -+ f.pty.closeMu.Lock() -+ closed := f.pty.closed -+ f.pty.closeMu.Unlock() -+ if !closed { -+ t.Fatal("expected pty.Close() to be called on kill") -+ } -+ -+ // Listener should be closed: new dial must fail. -+ conn, err := net.DialTimeout("tcp", f.addr, 200*time.Millisecond) -+ if err == nil { -+ _ = conn.Close() -+ t.Fatal("expected listener to be closed after kill, but Dial succeeded") -+ } -+ -+ c.close() -+} -+ -+// TestShutdownViaCtxCancel: cancelling the context triggers graceful shutdown. -+func TestShutdownViaCtxCancel(t *testing.T) { -+ f := startServe(t, 107) -+ -+ c := newTestClient(t, f.addr) -+ defer c.close() -+ -+ // Cancel the context. -+ f.cancel() -+ -+ f.waitDone(t) -+ -+ // PTY Close must have been called. -+ f.pty.closeMu.Lock() -+ closed := f.pty.closed -+ f.pty.closeMu.Unlock() -+ if !closed { -+ t.Fatal("expected pty.Close() on ctx cancel") -+ } -+} - ---- Changes --- - -backend/internal/adapters/runtime/conpty/host.go - @@ -0,0 +1,256 @@ - +// Package conpty - host.go implements the serve engine for the pty-host - +// detached process. It owns the agent's PTY (via the ptyConn seam), exposes - +// it over a loopback TCP socket using the B1 binary protocol, replays - +// scrollback to new clients, fans output to all connected clients, and shuts - +// down gracefully (ConPTY dispose first, then clients, then listener). - +// - +// This file is cross-platform; only the real conptyConn impl is Windows-tagged. - +package conpty - + - +import ( - + "context" - + "encoding/json" - + "io" - + "net" - + "sync" - + "time" - +) - + - +// ptyConn is the host's handle to the running agent's pseudo-terminal. - +// The real impl (conptyConn) lives in host_conpty_windows.go; tests use a fake. - +type ptyConn interface { - + io.Reader // PTY output (raw bytes from the terminal) - + io.Writer // PTY input (keystrokes to the terminal) - + Resize(cols, rows int) error - + Close() error // dispose the ConPTY - + Done() <-chan struct{} // closed when the child process exits - + ExitCode() (int, bool) // (code, true) once exited; (0, false) while running - + PID() int - +} - + - +// ServeConfig carries everything the host needs. - +type ServeConfig struct { - + SessionID string - + Listener net.Listener // caller provides (loopback); engine owns Accept loop - + PTY ptyConn - + Ring *Ring - +} - + - +// Serve runs the host event loop until the listener closes or Shutdown is - +// invoked via the returned ShutdownFunc. It pumps PTY output into the ring - +// and broadcasts to all clients, accepts new clients (replaying ring snapshot), - +// and dispatches client messages. On PTY exit it broadcasts a status update - +// but stays alive (keep-alive, mirroring tmux behavior). Returns when shut down. - +func Serve(ctx context.Context, cfg ServeConfig) error { - + h := &host{ - + cfg: cfg, - + clients: make(map[net.Conn]struct{}), - + shutdownC: make(chan struct{}), - + } - + return h.run(ctx) - +} - + - +// host holds the mutable state for a single pty-host session. - +type host struct { - + cfg ServeConfig - + mu sync.Mutex - + clients map[net.Conn]struct{} - + - + shutdownOnce sync.Once - + shutdownC chan struct{} // closed when Shutdown is called - +} - + - +// run is the main event loop. - +func (h *host) run(ctx context.Context) error { - + // Pump PTY output to ring + broadcast. - + go h.pumpPTY() - + - + // Watch for ctx cancellation and trigger shutdown. - + go func() { - + select { - + case <-ctx.Done(): - + h.shutdown() - + case <-h.shutdownC: - + } - + }() - + - + // Accept loop. - + for { - + conn, err := h.cfg.Listener.Accept() - + if err != nil { - + // Listener closed: either shutdown or external close. - + return nil - + } - + go h.handleConn(conn) - + } - +} - + - +// shutdown is idempotent: disposes the ConPTY, closes clients, closes the - +// listener. Mirrors the pty-host.ts shutdown() function. - +// ponytail: 50ms sleep after pty.Close() gives the OS ConPTY helper - +// (conpty_console_list_agent.exe) time to release cleanly; avoids the - +// 0x800700e8 error dialog on Windows. - +func (h *host) shutdown() { - + h.shutdownOnce.Do(func() { - + close(h.shutdownC) - + - + // 1. Dispose the ConPTY first (critical ordering). - + _ = h.cfg.PTY.Close() - + - + // 2. Brief grace so the OS ConPTY helper can clean up. - ... (156 lines truncated) - +256 -0 - -backend/internal/adapters/runtime/conpty/host_conpty_other.go - @@ -0,0 +1,12 @@ - +//go:build !windows - + - +package conpty - + - +import "errors" - + - +// newConPTY is a stub on non-Windows platforms. The serve engine (host.go) and - +// tests use a fake ptyConn; this stub only exists to keep the package buildable - +// on Darwin/Linux so the engine can be imported and tested without Windows. - +func newConPTY(cwd, shellCmd string, shellArgs []string) (ptyConn, error) { - + return nil, errors.New("conpty: unsupported on this OS") - +} - +12 -0 - -backend/internal/adapters/runtime/conpty/host_conpty_windows.go - @@ -0,0 +1,94 @@ - +//go:build windows - + - +package conpty - + - +import ( - + "fmt" - + "os/exec" - + "sync" - + - + gopty "github.com/aymanbagabas/go-pty" - +) - + - +// conptyConn is the real ptyConn implementation backed by go-pty's ConPty - +// (Windows ConPTY API). Only compiled on Windows. - +type conptyConn struct { - + pty gopty.ConPty - + cmd *gopty.Cmd - + - + once sync.Once - + doneC chan struct{} - + exitCode int - + exited bool - + exitMu sync.Mutex - +} - + - +// newConPTY creates a ConPTY session running shellCmd in cwd with shellArgs. - +// It starts the process and returns a ptyConn ready for use. - +func newConPTY(cwd, shellCmd string, shellArgs []string) (ptyConn, error) { - + // go-pty's New() returns a ConPty on Windows. - + p, err := gopty.New() - + if err != nil { - + return nil, fmt.Errorf("conpty: create pty: %w", err) - + } - + cp, ok := p.(gopty.ConPty) - + if !ok { - + _ = p.Close() - + return nil, fmt.Errorf("conpty: expected ConPty on windows, got %T", p) - + } - + - + // Set an initial size matching node-pty defaults from pty-host.ts. - + if err := cp.Resize(220, 50); err != nil { - + _ = cp.Close() - + return nil, fmt.Errorf("conpty: initial resize: %w", err) - + } - + - + cmd := cp.Command(shellCmd, shellArgs...) - + cmd.Dir = cwd - + // Inherit parent env so PATH, HOME, etc. are available. - + cmd.Env = exec.Command(shellCmd).Environ() - + - + if err := cmd.Start(); err != nil { - + _ = cp.Close() - + return nil, fmt.Errorf("conpty: start command: %w", err) - + } - + - + c := &conptyConn{ - + pty: cp, - + cmd: cmd, - + doneC: make(chan struct{}), - + } - + - + go c.wait() - + return c, nil - +} - + - +func (c *conptyConn) wait() { - + _ = c.cmd.Wait() - + code := 0 - + if c.cmd.ProcessState != nil { - + code = c.cmd.ProcessState.ExitCode() - + } - + c.exitMu.Lock() - + c.exitCode = code - + c.exited = true - + c.exitMu.Unlock() - + c.once.Do(func() { close(c.doneC) }) - +} - + - +func (c *conptyConn) Read(b []byte) (int, error) { return c.pty.Read(b) } - +func (c *conptyConn) Write(b []byte) (int, error) { return c.pty.Write(b) } - +func (c *conptyConn) Close() error { return c.pty.Close() } - +func (c *conptyConn) Resize(cols, rows int) error { return c.pty.Resize(cols, rows) } - +func (c *conptyConn) Done() <-chan struct{} { return c.doneC } - +func (c *conptyConn) PID() int { - + if c.cmd.Process == nil { - + return 0 - + } - + return c.cmd.Process.Pid - +} - +func (c *conptyConn) ExitCode() (int, bool) { - + c.exitMu.Lock() - + defer c.exitMu.Unlock() - + return c.exitCode, c.exited - +} - +94 -0 - -backend/internal/adapters/runtime/conpty/host_main.go - @@ -0,0 +1,84 @@ - +// host_main.go is the RunHost entrypoint for the "ao pty-host" subcommand. - +// It is cross-platform: the loopback TCP bind and signal wiring work on all - +// OSes; only the ConPTY creation (newConPTY) is OS-gated via build tags. - +package conpty - + - +import ( - + "context" - + "fmt" - + "io" - + "net" - + "os" - + "os/signal" - + "syscall" - +) - + - +// RunHost is the "ao pty-host" entrypoint. argv is everything after the - +// subcommand name: [shellArg...] - +// - +// It binds 127.0.0.1:0 (OS assigns the port), creates the ConPTY, prints - +// "READY: \n" to stdout (the parent process reads this to learn the - +// port), installs SIGTERM/SIGINT handlers, then runs Serve. Returns a process - +// exit code. - +// - +// ponytail: loopback bind only; any local process on this host can connect to - +// the assigned port. A per-session random token handshake is the upgrade path - +// if multi-user isolation is needed. - +func RunHost(args []string, stdout io.Writer) int { - + if len(args) < 3 { - + fmt.Fprintf(os.Stderr, "usage: ao pty-host [shellArg...]\n") - + return 1 - + } - + - + sessionID := args[0] - + cwd := args[1] - + shellCmd := args[2] - + shellArgs := args[3:] - + - + // Bind before creating the PTY so we can report READY atomically. - + ln, err := net.Listen("tcp", "127.0.0.1:0") - + if err != nil { - + fmt.Fprintf(os.Stderr, "pty-host [%s]: listen: %v\n", sessionID, err) - + return 1 - + } - + port := ln.Addr().(*net.TCPAddr).Port - + - + pty, err := newConPTY(cwd, shellCmd, shellArgs) - + if err != nil { - + _ = ln.Close() - + fmt.Fprintf(os.Stderr, "pty-host [%s]: newConPTY: %v\n", sessionID, err) - + return 1 - + } - + - + // Print READY after both the listener and the PTY are up. - + fmt.Fprintf(stdout, "READY:%d %d\n", pty.PID(), port) - + - + ctx, cancel := context.WithCancel(context.Background()) - + defer cancel() - + - + // Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown. - + sigC := make(chan os.Signal, 1) - + signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT) - + go func() { - + select { - + case sig := <-sigC: - + fmt.Fprintf(os.Stderr, "pty-host [%s]: signal %v, shutting down\n", sessionID, sig) - + cancel() - + case <-ctx.Done(): - + } - + }() - + - + ring := NewRing() - + cfg := ServeConfig{ - + SessionID: sessionID, - + Listener: ln, - + PTY: pty, - + Ring: ring, - + } - + - + if err := Serve(ctx, cfg); err != nil { - + fmt.Fprintf(os.Stderr, "pty-host [%s]: serve: %v\n", sessionID, err) - + return 1 - + } - + return 0 - +} - +84 -0 - -backend/internal/adapters/runtime/conpty/host_test.go - @@ -0,0 +1,481 @@ - +package conpty - + - +import ( - + "context" - + "encoding/json" - + "fmt" - + "io" - + "net" - + "sync" - + "testing" - + "time" - +) - + - +// --------------------------------------------------------------------------- - +// fakePTY implements ptyConn using in-memory pipes. Used only in tests; - +// the real ConPTY impl is Windows-only. - +// --------------------------------------------------------------------------- - + - +type fakePTY struct { - + // output is what the fake "terminal" writes to the host (PTY -> host reader) - + outR *io.PipeReader - + outW *io.PipeWriter - + - + // input is what the host writes to the fake terminal (keystrokes) - + inR *io.PipeReader - + inW *io.PipeWriter - + - + resizeMu sync.Mutex - + resizes []ResizePayload - + - + doneOnce sync.Once - + doneC chan struct{} - + exitCode int - + closed bool - + closeMu sync.Mutex - + - + pid int - +} - + - +func newFakePTY(pid int) *fakePTY { - + outR, outW := io.Pipe() - + inR, inW := io.Pipe() - + return &fakePTY{ - + outR: outR, - + outW: outW, - + inR: inR, - + inW: inW, - + doneC: make(chan struct{}), - + pid: pid, - + } - +} - + - +// WriteOutput simulates the PTY producing output (e.g. shell printing text). - +func (f *fakePTY) WriteOutput(data []byte) (int, error) { return f.outW.Write(data) } - + - +// CloseOutput simulates the PTY process exiting (closes the read side). - +func (f *fakePTY) CloseOutput(code int) { - + f.exitCode = code - + f.outW.Close() - +} - + - +// ReadInput lets tests inspect what the host forwarded to the PTY. - +func (f *fakePTY) ReadInput(buf []byte) (int, error) { return f.inR.Read(buf) } - + - +// ptyConn interface implementation. - +func (f *fakePTY) Read(b []byte) (int, error) { return f.outR.Read(b) } - +func (f *fakePTY) Write(b []byte) (int, error) { return f.inW.Write(b) } - + - +func (f *fakePTY) Resize(cols, rows int) error { - + f.resizeMu.Lock() - + defer f.resizeMu.Unlock() - + f.resizes = append(f.resizes, ResizePayload{Cols: cols, Rows: rows}) - + return nil - +} - + - +func (f *fakePTY) Close() error { - + f.closeMu.Lock() - + defer f.closeMu.Unlock() - + f.closed = true - + // Close both pipes so pumpPTY and any Read calls unblock. - + _ = f.outW.Close() - + _ = f.inW.Close() - + f.doneOnce.Do(func() { close(f.doneC) }) - + return nil - +} - + - +func (f *fakePTY) Done() <-chan struct{} { return f.doneC } - + - +func (f *fakePTY) ExitCode() (int, bool) { - + select { - + case <-f.doneC: - + return f.exitCode, true - + default: - + return 0, false - + } - +} - + - +func (f *fakePTY) PID() int { return f.pid } - + - +// signalExit simulates the child process exiting, triggering the Done channel - ... (381 lines truncated) - +481 -0 -[full diff: rtk git diff --no-compact] diff --git a/.superpowers/sdd/task-b4-brief.md b/.superpowers/sdd/task-b4-brief.md deleted file mode 100644 index 691bfe34..00000000 --- a/.superpowers/sdd/task-b4-brief.md +++ /dev/null @@ -1,139 +0,0 @@ -# Task B4: conpty runtime adapter (loopback pty-client + Create/Destroy/IsAlive/SendMessage/GetOutput) - -## Goal -Implement the conpty Runtime adapter that drives sessions via the B3 pty-host over -loopback TCP. It spawns a detached pty-host per session, and talks to it with the -B1 protocol. This task implements everything EXCEPT terminal attach (the -`Attach`/`Stream` method comes in B5, after the terminal interface changes). Ports -agent-orchestrator's `runtime-process/src/index.ts` (Windows branch) and -`pty-client.ts`. The process-spawn is behind a seam so the whole adapter is -unit-tested on this Darwin machine against an in-process B3 `Serve` + fake PTY. - -Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, -module `backend/`, branch `migrate-zellij-to-tmux-conpty`. Package: -`internal/adapters/runtime/conpty` (same package as B1/B3). Add `runtime.go`, -`client.go`, `spawn.go` (+ a windows-tagged spawn file) and tests. - -## References (port faithfully — read them) -- `agent-orchestrator/packages/plugins/runtime-process/src/pty-client.ts` - (connect, ptyHostSendMessage chunking 512 chars/15ms + 300ms Enter delay, - ptyHostGetOutput, ptyHostIsAlive STATUS probe, ptyHostKill). -- `agent-orchestrator/packages/plugins/runtime-process/src/index.ts` lines 56-175 - (Windows create: spawn detached, READY handshake, register in registry, return - handle) and 269-310 (destroy: kill via pipe, 500ms grace, force-kill, unregister). - -## Session model (important — ReverbCode specifics) -`ports.RuntimeHandle` is just `{ID string}` (opaque, no data map). The terminal -layer constructs handles as `ports.RuntimeHandle{ID: }` from the bare -session id, while the reaper/messenger pass the stored handle id (= what Create -returns). So: **Create returns `ports.RuntimeHandle{ID: }`** (bare), and -the adapter resolves a session by id through: -1. an in-memory `map[string]*hostSession` (sessionID -> {addr, ptyHostPID}), - populated by Create; then -2. a fallback lookup in the B2 registry (`ptyregistry.List()` / a by-id helper) so - a daemon that restarted (empty map) can still reach a detached pty-host that is - still listening. Store the loopback address (e.g. "127.0.0.1:54321") in the - registry Entry.PipePath field and the ptyHostPID in Entry.PtyHostPID. - -Validate session ids with `^[a-zA-Z0-9_-]+$` (port agent-orchestrator's -assertValidSessionId); reject invalid ones from Create. - -## The spawn seam (`spawn.go`) -```go -// hostSpawner starts a detached pty-host for the session and returns its loopback -// address ("127.0.0.1:PORT") and OS pid once it prints READY. Injectable for tests. -type hostSpawner func(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (addr string, pid int, err error) -``` -Default impl `defaultSpawnHost`: -- Resolve the current executable (`os.Executable()`); build argv: - ` pty-host ` where the agent argv - is wrapped so the shell runs it then keeps the session alive. ponytail: reuse the - existing Windows launch approach if simplest; at minimum the host's shell must - exec the agent argv. (The pty-host subcommand registration in the CLI is B6; this - task only needs to produce the correct argv and spawn it.) -- Start it DETACHED so it survives the daemon exit (mirror `detached: true`, - `windowsHide: true`). On non-windows use a stub that returns an error - ("conpty spawn: unsupported on this OS") in a `//go:build !windows` file; the real - detached-spawn (with the Windows process-creation flags via golang.org/x/sys/ - windows, already present) goes in a `//go:build windows` file. Read stdout for - `READY: ` with a 10s timeout, then unref/detach. -- Tests DO NOT use defaultSpawnHost; they inject a fake spawner that starts a B3 - `Serve` with a fake PTY on a real `127.0.0.1:0` listener and returns that addr. - -## pty-client helpers (`client.go`, cross-platform stdlib net, fully testable) -Port pty-client.ts as Go functions that dial the loopback addr each call (short-lived -connections, like the TS): -- `func dialHost(addr string, timeout time.Duration) (net.Conn, error)` -- `func clientSendMessage(addr, message string) error` — chunk message by 512 - runes, write each as MsgTerminalInput frame with 15ms gaps, then 300ms pause, then - one MsgTerminalInput frame with "\r". (Port ptyHostSendMessage; chunk by rune to - avoid splitting a UTF-8 codepoint — reuse B1's `chunks`-style splitting if a - helper exists, else split on rune boundaries.) -- `func clientGetOutput(addr string, lines int) (string, error)` — send - MsgGetOutputReq{lines}, read frames via MessageParser until MsgGetOutputRes, return - its text; 3s timeout returns "" (no error) like the TS. -- `func clientIsAlive(addr string) bool` — send MsgStatusReq, read until - MsgStatusRes, return true if valid JSON received; connect failure -> false; 2s - timeout -> false. (Mirror ptyHostIsAlive: host reachable == alive, regardless of - the inner agent's alive flag.) -- `func clientKill(addr string) error` — send MsgKillReq, best-effort; connect - failure is a no-op (already dead). - -## Runtime methods (`runtime.go`) -Implement the struct + these methods (the union the daemon wires, minus Attach which -is B5): -- `New(opts Options) *Runtime` (Options: Timeout, Chunksize defaults; keep minimal). -- `Create(ctx, cfg) (ports.RuntimeHandle, error)`: validate id/workspace/argv/env; - reject duplicate (map already has id); spawn via the spawner; store in map; - register in the B2 registry (addr, pid, RFC3339 timestamp passed in). Return - `{ID: sessionID}`. On spawn failure, clean up the map slot. -- `Destroy(ctx, handle)`: resolve addr+pid; `clientKill(addr)`; poll up to ~500ms - for the pid to exit (use a pidAlive-style probe; reuse ptyregistry's notion or a - small local probe); then best-effort force-kill the pid (`os.FindProcess(pid). - Kill()` — ponytail: kills the host, whose graceful shutdown already disposed the - child ConPTY; no full process-tree kill, upgrade if orphans appear). Remove from - map; `ptyregistry.Unregister(sessionID)`. Idempotent: unknown/already-gone session - returns nil. -- `IsAlive(ctx, handle) (bool, error)`: resolve addr; `clientIsAlive(addr)`. - Resolve-miss (no map entry, no registry entry) -> `(false, nil)` (definitively - gone, like a missing session). A dial/probe that errors transiently while an entry - EXISTS -> return `(false, nil)` only if the host is unreachable (gone); otherwise - it is alive. Keep the contract: never return an error that the reaper would treat - as death; if you cannot tell, return `(false, nil)` only when the host is truly - unreachable. (Match the reaper expectation used by tmux/zellij: definitively-dead - vs alive; for conpty, unreachable host == dead.) -- `SendMessage(ctx, handle, message)`: resolve addr; `clientSendMessage`. -- `GetOutput(ctx, handle, lines)`: resolve addr; `clientGetOutput`; lines<=0 -> error. -- Add `var _ ports.Runtime = (*Runtime)(nil)`. (Attach is added in B5; do not add it - here.) - -## Tests (run on Darwin against in-process B3 Serve + fake PTY) -Build a test harness: start a B3 `Serve` with a fakePTY on a `127.0.0.1:0` listener -(reuse the fakePTY pattern from host_test.go; you may need to export or duplicate a -minimal fake within the test file). Inject a fake spawner returning that addr+a fake -pid. Cover: -- Create registers the session (map + registry Entry present) and returns - `{ID: sessionID}`; duplicate Create errors; invalid session id errors. -- SendMessage delivers the chunked text + Enter to the fakePTY input (assert the - fake received message bytes followed by "\r"); large (>512-rune) message is - chunked. -- GetOutput returns the host's ring tail. -- IsAlive true while the host serves; false after the host listener is closed / - session unknown. -- Destroy calls clientKill (fakePTY Close observed), removes the map+registry entry, - and is idempotent on a second call. -- Resolve-via-registry path: with an empty in-memory map but a registry entry - pointing at a live in-process host, IsAlive/SendMessage still work (simulates a - daemon restart). - -## Definition of done (from `backend/`) -- `go build ./...` ; `GOOS=windows go build ./...` ; `GOOS=linux go build ./...` succeed. -- `go test -race ./internal/adapters/runtime/conpty/...` passes. -- `go vet ./internal/adapters/runtime/conpty/...` clean. - -## Hard rules -- Reuse B1 (proto/ring), B2 (ptyregistry), B3 (Serve/fakePTY pattern). No new go.mod - module (golang.org/x/sys/windows already present; windows-tagged spawn file only). -- Touch only files under `backend/internal/adapters/runtime/conpty/`. -- Never use em dashes ("—"). Minimal/idiomatic; mark shortcuts with `ponytail:`. -- Commit on the branch; trailer `Co-Authored-By: Claude Opus 4.8 `. diff --git a/.superpowers/sdd/task-b4-report.md b/.superpowers/sdd/task-b4-report.md deleted file mode 100644 index d9442695..00000000 --- a/.superpowers/sdd/task-b4-report.md +++ /dev/null @@ -1,103 +0,0 @@ -# Task B4 Report: conpty Runtime Adapter - -## Status: DONE - -## Files Created - -All under `backend/internal/adapters/runtime/conpty/`: - -- `client.go` - loopback TCP client helpers (dialHost, clientSendMessage, clientGetOutput, clientIsAlive, clientKill) -- `spawn.go` - hostSpawner type definition -- `spawn_other.go` (`//go:build !windows`) - stub returning "conpty spawn: unsupported on this OS" -- `spawn_windows.go` (`//go:build windows`) - real detached-process spawner using `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` and `HideWindow: true` via `golang.org/x/sys/windows` -- `pidalive_unix.go` (`//go:build !windows`) - `pidAlive` (signal-0 probe) + `defaultOSProcessFinder` -- `pidalive_windows.go` (`//go:build windows`) - `pidAlive` (OpenProcess/SYNCHRONIZE probe) + `defaultOSProcessFinder` -- `runtime.go` - `Runtime` struct, `New`, `Create`, `Destroy`, `IsAlive`, `SendMessage`, `GetOutput`, `resolve`, and `var _ ports.Runtime = (*Runtime)(nil)` compile-time assertion -- `runtime_test.go` - full test suite (13 test functions) - -## Session-Resolution + Spawn-Seam Design - -**Spawn seam:** `Options.Spawner` is a `hostSpawner` func. Production code uses `defaultSpawnHost` (Windows-only real spawn). Tests inject a fake spawner that starts an in-process `Serve` + `fakePTY` on a real `127.0.0.1:0` listener and returns that addr plus a fake PID. This makes all adapter methods testable on Darwin without a real ConPTY. - -**Session resolution:** `Runtime.resolve(id)` checks the in-memory `map[string]*hostSession` first (populated by `Create`). On a cache miss it calls `ptyregistry.List()` and scans for a matching `SessionID`, re-populating the map if found. This gives daemon-restart recovery: a pty-host spawned before a daemon restart is still reachable as long as its registry entry survives and its PID is alive. - -**Registry storage:** The loopback addr (e.g. "127.0.0.1:54321") is stored in `Entry.PipePath` (reusing the existing field whose semantic is "how to reach the host"). The pty-host OS PID is stored in `Entry.PtyHostPID` as specified. - -## Registry-Recovery Path - -`Create` calls `ptyregistry.Register` with the loopback addr in `PipePath` and the pty-host PID in `PtyHostPID`. `Destroy` calls `ptyregistry.Unregister`. `resolve` calls `ptyregistry.List()` (which auto-prunes dead-PID entries) and returns a `hostSession` if found. The `TestResolveViaRegistry` test verifies this: a `Runtime` with an empty in-memory map resolves `IsAlive` and `SendMessage` via the registry entry alone. - -**PID gotcha for tests:** `ptyregistry.List()` prunes entries whose `PtyHostPID` is not alive. Tests that need registry entries to survive the prune use `livePID()` (= `os.Getpid()`, always alive). The `Destroy` test uses `deadPID()` (= 2147483647, MaxInt32, never a real process) so the force-kill step is a safe no-op that does not accidentally kill the test runner. - -## Verification Command Outputs - -``` -go build ./... SUCCESS -GOOS=windows go build ./... SUCCESS -GOOS=linux go build ./... SUCCESS -go test -race ./internal/adapters/runtime/conpty/... 48 tests PASS (2 packages) -go vet ./internal/adapters/runtime/conpty/... No issues -``` - -## Windows-Only Spawn File (spawn_windows.go) - -`spawn_windows.go` was not executed (Darwin machine). The file compiles under `GOOS=windows go build ./...`. Key implementation notes: - -- Uses `golang.org/x/sys/windows.SysProcAttr.CreationFlags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` and `HideWindow: true`. This mirrors `detached: true, windowsHide: true` from the TS spawn. -- Reads stdout with a `bufio.Scanner` looking for `READY: ` (the format printed by `RunHost` in `host_main.go`). -- On success, calls `stdout.Close()` and `cmd.Process.Release()` to detach the child. `cmd.Process.Release()` is a best-effort detach; the error is intentionally ignored. -- 10s timeout matches the TS source (`10_000` ms). -- The env merge (inherit parent + overlay caller-provided vars) mirrors `{ ...process.env, ...config.environment }` from the TS. - -Concern: `cmd.Process.Release()` on Windows after `cmd.Start()` does not prevent the child from being reaped if our process exits; it only releases the OS handle held by the Go runtime. The child's `DETACHED_PROCESS` flag is what truly detaches it from our console group. This is correct behavior. - -## Interface Compliance - -`var _ ports.Runtime = (*Runtime)(nil)` compiles, confirming `Create`, `Destroy`, and `IsAlive` are all implemented with the correct signatures. `SendMessage` and `GetOutput` are exported but not part of the `ports.Runtime` interface - they are called by the daemon's messenger and output layers directly. `Attach` is intentionally omitted (B5). - ---- - -## Review fix: IsAlive reaper-safety (dead-vs-transient split) - -### The bug -`clientIsAlive` returned a bare `bool`, collapsing dial timeout, read-deadline -expiry, write error, and connection-refused all to `false`. The reaper turns -`(false, nil)` into `ProbeDead`, which the LCM can promote to a permanent -`IsTerminated` reap when the agent has also been quiet >60s. A single transient -2s loopback timeout on a normal idle session would then spuriously kill a live -session. tmux/zellij avoid this by returning a non-nil error for transient -failures (recorded as `ProbeFailed`, ignored and retried). - -### Fix (files touched, all under conpty/) -- `client.go`: `clientIsAlive` now returns `(alive bool, transientErr error)`: - - valid `MSG_STATUS_RES` -> `(true, nil)`. - - dial refused (nothing listening) -> `(false, nil)` definitively gone. - - dial timeout / read-deadline expiry / write error / mid-read EOF / no - STATUS_RES before conn end -> `(false, err)` transient. - - Added `isTimeout` (net.Error.Timeout()) and `isConnRefused` - (errors.Is ECONNREFUSED, plus WSAECONNREFUSED 10061 for older Windows). - "When unsure, prefer transient": any non-timeout, non-refused dial error - returns the error. - - `clientGetOutput` "return empty on failure" behavior left unchanged. -- `runtime.go`: `IsAlive` now propagates `clientIsAlive`'s `(bool, error)` - directly; the unknown-session path still returns `(false, nil)`. -- `runtime_test.go`: - - Updated `TestClientIsAlive_TrueAndFalse` for the new 2-value signature - (refused -> (false, nil)). - - Added regression `TestIsAlive_RefusedIsGone_TimeoutIsTransient`: - (a) resolved-but-refused host -> `(false, nil)`; - (b) resolved host behind a listener that Accepts but never replies, so the - short `isAliveTimeout` read deadline fires -> `(false, non-nil err)`. - -### Command outputs (from backend/) -- `go build ./...` : Success -- `GOOS=windows go build ./...`: Success -- `GOOS=linux go build ./...` : Success -- `go test -race ./internal/adapters/runtime/conpty/...` : 49 passed in 2 packages -- `go vet ./internal/adapters/runtime/conpty/...` : No issues found -- New test verbose: TestIsAlive_RefusedIsGone_TimeoutIsTransient PASS (2.00s) - -### READY-format confirmation (host_main.go, not modified) -`host_main.go:54` prints `fmt.Fprintf(stdout, "READY:%d %d\n", pty.PID(), port)` -i.e. `READY: ` (pid THEN port), which matches spawn_windows.go's -`READY:(\d+) (\d+)` regex. No action needed (and out of scope for this task). diff --git a/.superpowers/sdd/task-b4-review-package.txt b/.superpowers/sdd/task-b4-review-package.txt deleted file mode 100644 index a2e1bfcc..00000000 --- a/.superpowers/sdd/task-b4-review-package.txt +++ /dev/null @@ -1,1806 +0,0 @@ -=== COMMITS === -4aac665 feat(conpty): add runtime adapter with loopback pty-client and sessio... - -=== STAT === -backend/internal/adapters/runtime/conpty/client.go | 179 ++++++ - .../adapters/runtime/conpty/pidalive_unix.go | 26 + - .../adapters/runtime/conpty/pidalive_windows.go | 30 + - .../internal/adapters/runtime/conpty/runtime.go | 224 ++++++++ - .../adapters/runtime/conpty/runtime_test.go | 612 +++++++++++++++++++++ - backend/internal/adapters/runtime/conpty/spawn.go | 11 + - .../adapters/runtime/conpty/spawn_other.go | 16 + - .../adapters/runtime/conpty/spawn_windows.go | 121 ++++ - 8 files changed, 1219 insertions(+) - -=== DIFF === -backend/internal/adapters/runtime/conpty/client.go | 179 ++++++ - .../adapters/runtime/conpty/pidalive_unix.go | 26 + - .../adapters/runtime/conpty/pidalive_windows.go | 30 + - .../internal/adapters/runtime/conpty/runtime.go | 224 ++++++++ - .../adapters/runtime/conpty/runtime_test.go | 612 +++++++++++++++++++++ - backend/internal/adapters/runtime/conpty/spawn.go | 11 + - .../adapters/runtime/conpty/spawn_other.go | 16 + - .../adapters/runtime/conpty/spawn_windows.go | 121 ++++ - 8 files changed, 1219 insertions(+) - -diff --git a/backend/internal/adapters/runtime/conpty/client.go b/backend/internal/adapters/runtime/conpty/client.go -new file mode 100644 -index 0000000..0ce7f61 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/client.go -@@ -0,0 +1,179 @@ -+// client.go - loopback TCP client helpers that mirror pty-client.ts. -+// Each function dials the host addr fresh (short-lived connection) and -+// returns without maintaining state. Cross-platform: uses only stdlib net. -+package conpty -+ -+import ( -+ "encoding/json" -+ "net" -+ "time" -+) -+ -+const ( -+ // ptyInputChunkRunes is the max runes per terminal-input frame. -+ // Mirrors PTY_INPUT_CHUNK_CHARS in pty-client.ts. -+ ptyInputChunkRunes = 512 -+ // ptyInputChunkDelay is the inter-chunk delay. Mirrors PTY_INPUT_CHUNK_DELAY_MS. -+ ptyInputChunkDelay = 15 * time.Millisecond -+ // ptyInputEnterDelay is the pause before sending Enter. Mirrors PTY_INPUT_ENTER_DELAY_MS. -+ ptyInputEnterDelay = 300 * time.Millisecond -+ -+ dialTimeout = 3 * time.Second -+ getOutputTimeout = 3 * time.Second -+ isAliveTimeout = 2 * time.Second -+) -+ -+// dialHost opens a TCP connection to addr with a deadline. Callers close it. -+func dialHost(addr string, timeout time.Duration) (net.Conn, error) { -+ return net.DialTimeout("tcp", addr, timeout) -+} -+ -+// clientSendMessage chunks message by 512 runes and sends each as a -+// MsgTerminalInput frame with 15ms gaps, then pauses 300ms and sends "\r". -+// Mirrors ptyHostSendMessage from pty-client.ts. -+func clientSendMessage(addr, message string) error { -+ conn, err := dialHost(addr, dialTimeout) -+ if err != nil { -+ return err -+ } -+ defer conn.Close() -+ -+ runes := []rune(message) -+ for i := 0; i < len(runes); i += ptyInputChunkRunes { -+ end := i + ptyInputChunkRunes -+ if end > len(runes) { -+ end = len(runes) -+ } -+ chunk := string(runes[i:end]) -+ frame := EncodeMessage(MsgTerminalInput, []byte(chunk)) -+ if _, err := conn.Write(frame); err != nil { -+ return err -+ } -+ // Inter-chunk delay only between chunks, not after the last one. -+ if end < len(runes) { -+ time.Sleep(ptyInputChunkDelay) -+ } -+ } -+ -+ // Brief pause before Enter (matches TS: Enter sent as a separate frame). -+ time.Sleep(ptyInputEnterDelay) -+ frame := EncodeMessage(MsgTerminalInput, []byte("\r")) -+ _, err = conn.Write(frame) -+ return err -+} -+ -+// clientGetOutput sends MsgGetOutputReq and reads frames until MsgGetOutputRes. -+// Returns "" on timeout or connection failure (no error), matching the TS. -+// lines <= 0 is handled by the caller (runtime.go rejects it before calling). -+func clientGetOutput(addr string, lines int) (string, error) { -+ conn, err := dialHost(addr, getOutputTimeout) -+ if err != nil { -+ return "", nil // ponytail: connect failure -> "" like the TS -+ } -+ defer conn.Close() -+ -+ _ = conn.SetDeadline(time.Now().Add(getOutputTimeout)) -+ -+ req, _ := json.Marshal(GetOutputReq{Lines: lines}) -+ if _, err := conn.Write(EncodeMessage(MsgGetOutputReq, req)); err != nil { -+ return "", nil -+ } -+ -+ resultC := make(chan string, 1) -+ parser := NewMessageParser(func(msgType byte, payload []byte) { -+ if msgType == MsgGetOutputRes { -+ select { -+ case resultC <- string(payload): -+ default: -+ } -+ } -+ }) -+ -+ buf := make([]byte, 4096) -+ for { -+ n, err := conn.Read(buf) -+ if n > 0 { -+ parser.Feed(buf[:n]) -+ } -+ select { -+ case text := <-resultC: -+ return text, nil -+ default: -+ } -+ if err != nil { -+ break -+ } -+ } -+ // Drain the channel one last time after the read loop ends. -+ select { -+ case text := <-resultC: -+ return text, nil -+ default: -+ return "", nil // timeout or EOF before response -+ } -+} -+ -+// clientIsAlive probes the host with MsgStatusReq. Returns true if a valid -+// MsgStatusRes with parseable JSON is received. Connect failure or timeout -+// returns false. Mirrors ptyHostIsAlive from pty-client.ts: host reachable -+// == alive, regardless of the inner agent's alive field. -+func clientIsAlive(addr string) bool { -+ conn, err := dialHost(addr, isAliveTimeout) -+ if err != nil { -+ return false -+ } -+ defer conn.Close() -+ -+ _ = conn.SetDeadline(time.Now().Add(isAliveTimeout)) -+ -+ if _, err := conn.Write(EncodeMessage(MsgStatusReq, nil)); err != nil { -+ return false -+ } -+ -+ aliveC := make(chan bool, 1) -+ parser := NewMessageParser(func(msgType byte, payload []byte) { -+ if msgType == MsgStatusRes { -+ var sp StatusPayload -+ alive := json.Unmarshal(payload, &sp) == nil -+ select { -+ case aliveC <- alive: -+ default: -+ } -+ } -+ }) -+ -+ buf := make([]byte, 4096) -+ for { -+ n, err := conn.Read(buf) -+ if n > 0 { -+ parser.Feed(buf[:n]) -+ } -+ select { -+ case result := <-aliveC: -+ return result -+ default: -+ } -+ if err != nil { -+ break -+ } -+ } -+ select { -+ case result := <-aliveC: -+ return result -+ default: -+ return false -+ } -+} -+ -+// clientKill sends MsgKillReq best-effort. Connect failure is a no-op -+// (host already dead). Mirrors ptyHostKill from pty-client.ts. -+func clientKill(addr string) error { -+ conn, err := dialHost(addr, isAliveTimeout) -+ if err != nil { -+ return nil // already dead -+ } -+ defer conn.Close() -+ _ = conn.SetDeadline(time.Now().Add(isAliveTimeout)) -+ _, _ = conn.Write(EncodeMessage(MsgKillReq, nil)) -+ return nil -+} -diff --git a/backend/internal/adapters/runtime/conpty/pidalive_unix.go b/backend/internal/adapters/runtime/conpty/pidalive_unix.go -new file mode 100644 -index 0000000..52f463e ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/pidalive_unix.go -@@ -0,0 +1,26 @@ -+//go:build !windows -+ -+package conpty -+ -+import ( -+ "errors" -+ "os" -+ "syscall" -+) -+ -+// pidAlive probes PID liveness via signal 0. nil and EPERM both mean alive -+// (process exists but may not be signallable). ESRCH means dead. -+// Mirrors ptyregistry.defaultPidAlive (same signal-0 pattern). -+func pidAlive(pid int) bool { -+ err := syscall.Kill(pid, 0) -+ if err == nil { -+ return true -+ } -+ return errors.Is(err, syscall.EPERM) -+} -+ -+// defaultOSProcessFinder wraps os.FindProcess for Unix (always succeeds on -+// Unix; the returned handle is valid for Kill). -+func defaultOSProcessFinder(pid int) (processKiller, error) { -+ return os.FindProcess(pid) -+} -diff --git a/backend/internal/adapters/runtime/conpty/pidalive_windows.go b/backend/internal/adapters/runtime/conpty/pidalive_windows.go -new file mode 100644 -index 0000000..a70a842 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/pidalive_windows.go -@@ -0,0 +1,30 @@ -+//go:build windows -+ -+package conpty -+ -+import ( -+ "fmt" -+ "os" -+ -+ "golang.org/x/sys/windows" -+) -+ -+// pidAlive probes PID liveness on Windows by opening the process handle with -+// SYNCHRONIZE (minimal permission). Failure means the process is gone. -+func pidAlive(pid int) bool { -+ h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) -+ if err != nil { -+ return false -+ } -+ _ = windows.CloseHandle(h) -+ return true -+} -+ -+// defaultOSProcessFinder wraps os.FindProcess for Windows. -+func defaultOSProcessFinder(pid int) (processKiller, error) { -+ p, err := os.FindProcess(pid) -+ if err != nil { -+ return nil, fmt.Errorf("os.FindProcess(%d): %w", pid, err) -+ } -+ return p, nil -+} -diff --git a/backend/internal/adapters/runtime/conpty/runtime.go b/backend/internal/adapters/runtime/conpty/runtime.go -new file mode 100644 -index 0000000..e8abeeb ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/runtime.go -@@ -0,0 +1,224 @@ -+// runtime.go - conpty Runtime adapter. Implements ports.Runtime (minus Attach, -+// which comes in B5). Drives sessions via the B3 pty-host over loopback TCP, -+// using the B1 protocol and the B2 registry for restart recovery. -+package conpty -+ -+import ( -+ "context" -+ "fmt" -+ "regexp" -+ "sync" -+ "time" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" -+) -+ -+// Ensure Runtime satisfies the port at compile time. Attach is added in B5. -+var _ ports.Runtime = (*Runtime)(nil) -+ -+// validSessionID matches agent-orchestrator's assertValidSessionId. -+var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) -+ -+// hostSession is the in-memory state for a live pty-host connection. -+type hostSession struct { -+ addr string -+ pid int -+} -+ -+// Options configures the Runtime. All fields are optional; zero values use -+// sensible defaults. The Spawner field is injectable for tests. -+type Options struct { -+ // Spawner overrides the default OS-level process spawner. If nil, -+ // defaultSpawnHost is used (Windows-only; returns an error on other OSes). -+ Spawner hostSpawner -+} -+ -+// Runtime is the conpty runtime adapter. -+type Runtime struct { -+ spawner hostSpawner -+ -+ mu sync.Mutex -+ sessions map[string]*hostSession // sessionID -> live session -+} -+ -+// New creates a Runtime with the given options. -+func New(opts Options) *Runtime { -+ sp := opts.Spawner -+ if sp == nil { -+ sp = defaultSpawnHost -+ } -+ return &Runtime{ -+ spawner: sp, -+ sessions: make(map[string]*hostSession), -+ } -+} -+ -+// Create spawns a detached pty-host for the session, waits for READY, stores -+// the addr+pid in-memory and in the B2 registry, and returns the handle. -+// Returns an error if sessionID is invalid, already exists, or spawn fails. -+func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { -+ id := string(cfg.SessionID) -+ if !validSessionID.MatchString(id) { -+ return ports.RuntimeHandle{}, fmt.Errorf("conpty: invalid session id %q: must match ^[a-zA-Z0-9_-]+$", id) -+ } -+ if cfg.WorkspacePath == "" { -+ return ports.RuntimeHandle{}, fmt.Errorf("conpty: workspace path required") -+ } -+ if len(cfg.Argv) == 0 { -+ return ports.RuntimeHandle{}, fmt.Errorf("conpty: argv required") -+ } -+ -+ r.mu.Lock() -+ if _, dup := r.sessions[id]; dup { -+ r.mu.Unlock() -+ return ports.RuntimeHandle{}, fmt.Errorf("conpty: session %q already exists; destroy before re-creating", id) -+ } -+ // Reserve the slot before the async spawn so a concurrent Create for the -+ // same id fails immediately (no gap between check and set). -+ r.sessions[id] = nil -+ r.mu.Unlock() -+ -+ addr, pid, err := r.spawner(ctx, id, cfg.WorkspacePath, cfg.Argv, cfg.Env) -+ if err != nil { -+ r.mu.Lock() -+ delete(r.sessions, id) -+ r.mu.Unlock() -+ return ports.RuntimeHandle{}, fmt.Errorf("conpty: spawn pty-host for %q: %w", id, err) -+ } -+ -+ sess := &hostSession{addr: addr, pid: pid} -+ -+ r.mu.Lock() -+ r.sessions[id] = sess -+ r.mu.Unlock() -+ -+ // Register in B2 registry for daemon-restart recovery (best-effort). -+ _ = ptyregistry.Register(ptyregistry.Entry{ -+ SessionID: id, -+ PtyHostPID: pid, -+ PipePath: addr, // ponytail: reuse PipePath field for loopback addr -+ RegisteredAt: time.Now().UTC().Format(time.RFC3339), -+ }) -+ -+ return ports.RuntimeHandle{ID: id}, nil -+} -+ -+// Destroy gracefully kills the pty-host, waits up to ~500ms for the pid to -+// exit, then force-kills it. Removes the session from the map and the registry. -+// Idempotent: unknown/already-gone session returns nil. -+func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error { -+ sess := r.resolve(handle.ID) -+ if sess == nil { -+ return nil // unknown or already gone -+ } -+ -+ // Ask host to shut down gracefully (triggers shutdown() in Serve). -+ _ = clientKill(sess.addr) -+ -+ // Poll up to ~500ms (20 x 25ms) for the pty-host pid to exit. -+ // ponytail: signal-0 probe; upgrade to process-tree kill if orphan ConPTY -+ // helpers appear. -+ deadline := time.Now().Add(500 * time.Millisecond) -+ for time.Now().Before(deadline) { -+ if !pidAlive(sess.pid) { -+ break -+ } -+ time.Sleep(25 * time.Millisecond) -+ } -+ -+ // Best-effort force-kill (the host's graceful shutdown already disposed -+ // the ConPTY child; killing the host process is sufficient). -+ if p, err := findProcess(sess.pid); err == nil { -+ _ = p.Kill() -+ } -+ -+ r.mu.Lock() -+ delete(r.sessions, handle.ID) -+ r.mu.Unlock() -+ -+ _ = ptyregistry.Unregister(handle.ID) -+ return nil -+} -+ -+// IsAlive returns (true, nil) if the pty-host is reachable, (false, nil) if -+// not or if the session is unknown. Never returns a non-nil error that a reaper -+// would treat as a death conclusion: unreachable host == definitively dead. -+func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) { -+ sess := r.resolve(handle.ID) -+ if sess == nil { -+ return false, nil // no in-memory entry, no registry entry -> definitively gone -+ } -+ return clientIsAlive(sess.addr), nil -+} -+ -+// SendMessage chunks message and writes it to the pty-host followed by Enter. -+func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { -+ sess := r.resolve(handle.ID) -+ if sess == nil { -+ return fmt.Errorf("conpty: session %q not found", handle.ID) -+ } -+ return clientSendMessage(sess.addr, message) -+} -+ -+// GetOutput returns the last lines lines from the pty-host ring buffer. -+func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { -+ if lines <= 0 { -+ return "", fmt.Errorf("conpty: lines must be > 0") -+ } -+ sess := r.resolve(handle.ID) -+ if sess == nil { -+ return "", fmt.Errorf("conpty: session %q not found", handle.ID) -+ } -+ return clientGetOutput(sess.addr, lines) -+} -+ -+// resolve looks up a session by id: first the in-memory map, then the B2 -+// registry (for daemon-restart recovery). Returns nil if not found either way. -+func (r *Runtime) resolve(id string) *hostSession { -+ r.mu.Lock() -+ sess := r.sessions[id] -+ r.mu.Unlock() -+ if sess != nil { -+ return sess -+ } -+ -+ // Registry fallback: scan for the entry by session id. -+ entries, err := ptyregistry.List() -+ if err != nil { -+ return nil -+ } -+ for _, e := range entries { -+ if e.SessionID == id { -+ // Re-populate the map so subsequent calls skip the file scan. -+ recovered := &hostSession{addr: e.PipePath, pid: e.PtyHostPID} -+ r.mu.Lock() -+ // Only store if another goroutine hasn't beaten us. -+ if r.sessions[id] == nil { -+ r.sessions[id] = recovered -+ } else { -+ recovered = r.sessions[id] -+ } -+ r.mu.Unlock() -+ return recovered -+ } -+ } -+ return nil -+} -+ -+// findProcess wraps os.FindProcess to make it swappable in tests. -+// ponytail: direct call; no interface needed at this scale. -+func findProcess(pid int) (processKiller, error) { -+ p, err := osProcessFinder(pid) -+ return p, err -+} -+ -+// processKiller is the subset of *os.Process used by Destroy. -+type processKiller interface { -+ Kill() error -+} -+ -+// osProcessFinder is the production implementation; tests may replace it. -+// The real defaultOSProcessFinder is in pidalive_unix.go / pidalive_windows.go -+// (same files that provide pidAlive). -+var osProcessFinder = defaultOSProcessFinder -diff --git a/backend/internal/adapters/runtime/conpty/runtime_test.go b/backend/internal/adapters/runtime/conpty/runtime_test.go -new file mode 100644 -index 0000000..459a9f0 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/runtime_test.go -@@ -0,0 +1,612 @@ -+package conpty -+ -+import ( -+ "bytes" -+ "context" -+ "fmt" -+ "io" -+ "net" -+ "os" -+ "strings" -+ "testing" -+ "time" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" -+ "github.com/aoagents/agent-orchestrator/backend/internal/domain" -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" -+) -+ -+// livePID returns a PID that is guaranteed to be alive (the current process). -+// Using this as the fake pty-host PID means ptyregistry.List() will not prune -+// the entry during tests. Do NOT use this for the Destroy test: Destroy calls -+// Kill on the pid, so use deadPID() there instead. -+func livePID() int { return os.Getpid() } -+ -+// deadPID returns a PID that is guaranteed to be dead (no process). This is -+// used in Destroy tests so the force-kill step is a safe no-op. -+// ponytail: PID 2147483647 (MaxInt32) is never a real process; signal-0 returns ESRCH. -+func deadPID() int { return 2147483647 } -+ -+// --------------------------------------------------------------------------- -+// Test harness: in-process pty-host backed by a fakePTY. -+// --------------------------------------------------------------------------- -+ -+// inProcHost starts a Serve engine with a fakePTY on a real 127.0.0.1:0 -+// listener and returns a fake spawner that returns that addr and a fake pid. -+// The caller must call cleanup() to shut down the host. -+type inProcHost struct { -+ addr string -+ pid int -+ pty *fakePTY -+ ring *Ring -+ cancel context.CancelFunc -+ done chan error -+ ln net.Listener -+} -+ -+func startInProcHost(t *testing.T, sessionID string, fakePID int) *inProcHost { -+ t.Helper() -+ ln, err := net.Listen("tcp", "127.0.0.1:0") -+ if err != nil { -+ t.Fatalf("listen: %v", err) -+ } -+ pty := newFakePTY(fakePID) -+ ring := NewRing() -+ ctx, cancel := context.WithCancel(context.Background()) -+ done := make(chan error, 1) -+ go func() { -+ done <- Serve(ctx, ServeConfig{ -+ SessionID: sessionID, -+ Listener: ln, -+ PTY: pty, -+ Ring: ring, -+ }) -+ }() -+ return &inProcHost{ -+ addr: ln.Addr().String(), -+ pid: fakePID, -+ pty: pty, -+ ring: ring, -+ cancel: cancel, -+ done: done, -+ ln: ln, -+ } -+} -+ -+func (h *inProcHost) cleanup(t *testing.T) { -+ t.Helper() -+ h.cancel() -+ select { -+ case <-h.done: -+ case <-time.After(2 * time.Second): -+ t.Log("warning: inProcHost did not stop within 2s") -+ } -+} -+ -+// fakeSpawnerFor returns a hostSpawner that starts an in-process host for a -+// single session ID and records which sessions have been spawned. -+// The returned map maps sessionID -> *inProcHost for test inspection. -+func fakeSpawnerFor(t *testing.T, hosts map[string]*inProcHost, fakePID int) hostSpawner { -+ t.Helper() -+ return func(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (string, int, error) { -+ h := startInProcHost(t, sessionID, fakePID) -+ if hosts != nil { -+ hosts[sessionID] = h -+ } -+ return h.addr, h.pid, nil -+ } -+} -+ -+// --------------------------------------------------------------------------- -+// Redirect ptyregistry to a temp HOME so tests don't pollute ~/.ao -+// --------------------------------------------------------------------------- -+ -+func isolateRegistry(t *testing.T) { -+ t.Helper() -+ dir := t.TempDir() -+ t.Setenv("HOME", dir) -+} -+ -+// --------------------------------------------------------------------------- -+// Tests -+// --------------------------------------------------------------------------- -+ -+// TestCreate_RegistersSession verifies Create returns {ID: sessionID}, writes -+// to the in-memory map, and registers in the ptyregistry. -+func TestCreate_RegistersSession(t *testing.T) { -+ isolateRegistry(t) -+ hosts := map[string]*inProcHost{} -+ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) -+ -+ ctx := context.Background() -+ handle, err := rt.Create(ctx, ports.RuntimeConfig{ -+ SessionID: domain.SessionID("sess-abc"), -+ WorkspacePath: "/tmp/workspace", -+ Argv: []string{"claude-code"}, -+ }) -+ if err != nil { -+ t.Fatalf("Create: %v", err) -+ } -+ -+ if handle.ID != "sess-abc" { -+ t.Fatalf("handle.ID = %q, want %q", handle.ID, "sess-abc") -+ } -+ -+ // In-memory map must have the entry. -+ rt.mu.Lock() -+ sess := rt.sessions["sess-abc"] -+ rt.mu.Unlock() -+ if sess == nil { -+ t.Fatal("session not in in-memory map after Create") -+ } -+ -+ // Registry must have the entry. -+ entries, err := ptyregistry.List() -+ if err != nil { -+ t.Fatalf("List: %v", err) -+ } -+ var found bool -+ for _, e := range entries { -+ if e.SessionID == "sess-abc" { -+ found = true -+ } -+ } -+ if !found { -+ t.Fatal("session not in registry after Create") -+ } -+ -+ hosts["sess-abc"].cleanup(t) -+} -+ -+// TestCreate_DuplicateErrors verifies a second Create for the same session id fails. -+func TestCreate_DuplicateErrors(t *testing.T) { -+ isolateRegistry(t) -+ hosts := map[string]*inProcHost{} -+ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) -+ ctx := context.Background() -+ -+ if _, err := rt.Create(ctx, ports.RuntimeConfig{ -+ SessionID: "sess-dup", -+ WorkspacePath: "/tmp/w", -+ Argv: []string{"sh"}, -+ }); err != nil { -+ t.Fatalf("first Create: %v", err) -+ } -+ -+ _, err := rt.Create(ctx, ports.RuntimeConfig{ -+ SessionID: "sess-dup", -+ WorkspacePath: "/tmp/w", -+ Argv: []string{"sh"}, -+ }) -+ if err == nil { -+ t.Fatal("expected error on duplicate Create, got nil") -+ } -+ if !strings.Contains(err.Error(), "already exists") { -+ t.Fatalf("error %q should contain 'already exists'", err.Error()) -+ } -+ -+ hosts["sess-dup"].cleanup(t) -+} -+ -+// TestCreate_InvalidIDErrors verifies Create rejects invalid session ids. -+func TestCreate_InvalidIDErrors(t *testing.T) { -+ isolateRegistry(t) -+ rt := New(Options{Spawner: fakeSpawnerFor(t, nil, livePID())}) -+ ctx := context.Background() -+ -+ for _, bad := range []string{"", "has space", "has/slash", "has.dot"} { -+ _, err := rt.Create(ctx, ports.RuntimeConfig{ -+ SessionID: domain.SessionID(bad), -+ WorkspacePath: "/tmp/w", -+ Argv: []string{"sh"}, -+ }) -+ if err == nil { -+ t.Fatalf("Create(%q): expected error for invalid id, got nil", bad) -+ } -+ } -+} -+ -+// TestSendMessage_DeliversChunkedTextAndEnter verifies clientSendMessage sends -+// the text + "\r" to the fakePTY input. -+func TestSendMessage_DeliversChunkedTextAndEnter(t *testing.T) { -+ isolateRegistry(t) -+ hosts := map[string]*inProcHost{} -+ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) -+ ctx := context.Background() -+ -+ handle, err := rt.Create(ctx, ports.RuntimeConfig{ -+ SessionID: "sess-sm", -+ WorkspacePath: "/tmp/w", -+ Argv: []string{"sh"}, -+ }) -+ if err != nil { -+ t.Fatalf("Create: %v", err) -+ } -+ h := hosts["sess-sm"] -+ defer h.cleanup(t) -+ -+ msg := "hello world" -+ // Collect PTY input in background. -+ inputC := make(chan []byte, 4) -+ go func() { -+ buf := make([]byte, 1024) -+ for { -+ n, err := h.pty.inR.Read(buf) -+ if n > 0 { -+ cp := make([]byte, n) -+ copy(cp, buf[:n]) -+ inputC <- cp -+ } -+ if err != nil { -+ return -+ } -+ } -+ }() -+ -+ if err := rt.SendMessage(ctx, handle, msg); err != nil { -+ t.Fatalf("SendMessage: %v", err) -+ } -+ -+ // Collect all received bytes within 2s. -+ var received []byte -+ deadline := time.After(2 * time.Second) -+ // Expect at least msg + "\r". -+ for !bytes.Contains(received, []byte("\r")) { -+ select { -+ case chunk := <-inputC: -+ received = append(received, chunk...) -+ case <-deadline: -+ t.Fatalf("timeout waiting for PTY input; got %q so far", received) -+ } -+ } -+ -+ if !bytes.HasPrefix(received, []byte(msg)) { -+ t.Fatalf("PTY input = %q, want prefix %q then \\r", received, msg) -+ } -+ if !bytes.Contains(received, []byte("\r")) { -+ t.Fatalf("PTY input = %q, missing trailing \\r", received) -+ } -+} -+ -+// TestSendMessage_LargeMessageChunked verifies a message > 512 runes is -+// delivered correctly (host receives full text + "\r"). -+func TestSendMessage_LargeMessageChunked(t *testing.T) { -+ isolateRegistry(t) -+ hosts := map[string]*inProcHost{} -+ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) -+ ctx := context.Background() -+ -+ handle, _ := rt.Create(ctx, ports.RuntimeConfig{ -+ SessionID: "sess-lg", -+ WorkspacePath: "/tmp/w", -+ Argv: []string{"sh"}, -+ }) -+ h := hosts["sess-lg"] -+ defer h.cleanup(t) -+ -+ // Build a message longer than 512 runes (use multi-byte runes to test -+ // rune-boundary splitting). -+ var sb strings.Builder -+ for i := 0; i < 600; i++ { -+ sb.WriteRune('A' + rune(i%26)) -+ } -+ msg := sb.String() -+ -+ inputDone := make(chan []byte, 1) -+ go func() { -+ // Read until we see "\r". -+ var acc []byte -+ buf := make([]byte, 4096) -+ for { -+ n, err := h.pty.inR.Read(buf) -+ if n > 0 { -+ acc = append(acc, buf[:n]...) -+ } -+ if bytes.Contains(acc, []byte("\r")) { -+ inputDone <- acc -+ return -+ } -+ if err != nil { -+ inputDone <- acc -+ return -+ } -+ } -+ }() -+ -+ if err := rt.SendMessage(ctx, handle, msg); err != nil { -+ t.Fatalf("SendMessage: %v", err) -+ } -+ -+ select { -+ case got := <-inputDone: -+ // Strip trailing \r for comparison. -+ trimmed := strings.TrimSuffix(string(got), "\r") -+ if trimmed != msg { -+ t.Fatalf("PTY received %d chars, want %d\ngot: %q\nwant: %q", len(trimmed), len(msg), trimmed[:min(50, len(trimmed))], msg[:min(50, len(msg))]) -+ } -+ case <-time.After(5 * time.Second): -+ t.Fatal("timeout waiting for large message delivery") -+ } -+} -+ -+// TestGetOutput_ReturnsRingTail verifies GetOutput returns the ring's tail. -+func TestGetOutput_ReturnsRingTail(t *testing.T) { -+ isolateRegistry(t) -+ hosts := map[string]*inProcHost{} -+ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) -+ ctx := context.Background() -+ -+ handle, _ := rt.Create(ctx, ports.RuntimeConfig{ -+ SessionID: "sess-go", -+ WorkspacePath: "/tmp/w", -+ Argv: []string{"sh"}, -+ }) -+ h := hosts["sess-go"] -+ defer h.cleanup(t) -+ -+ // Seed the ring. -+ h.ring.Append([]byte("line1\nline2\nline3\n")) -+ -+ text, err := rt.GetOutput(ctx, handle, 2) -+ if err != nil { -+ t.Fatalf("GetOutput: %v", err) -+ } -+ want := h.ring.Tail(2) -+ if text != want { -+ t.Fatalf("GetOutput = %q, want %q", text, want) -+ } -+} -+ -+// TestIsAlive_TrueWhileServing_FalseAfterClose verifies IsAlive returns true -+// while the host listens and false after its listener is closed. -+func TestIsAlive_TrueWhileServing_FalseAfterClose(t *testing.T) { -+ isolateRegistry(t) -+ hosts := map[string]*inProcHost{} -+ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())}) -+ ctx := context.Background() -+ -+ handle, _ := rt.Create(ctx, ports.RuntimeConfig{ -+ SessionID: "sess-ia", -+ WorkspacePath: "/tmp/w", -+ Argv: []string{"sh"}, -+ }) -+ h := hosts["sess-ia"] -+ -+ alive, err := rt.IsAlive(ctx, handle) -+ if err != nil { -+ t.Fatalf("IsAlive: %v", err) -+ } -+ if !alive { -+ t.Fatal("expected IsAlive=true while serving") -+ } -+ -+ // Shut down the host. -+ h.cancel() -+ <-h.done -+ -+ // Give the listener a moment to close. -+ time.Sleep(100 * time.Millisecond) -+ -+ alive2, err2 := rt.IsAlive(ctx, handle) -+ if err2 != nil { -+ t.Fatalf("IsAlive after close: %v", err2) -+ } -+ if alive2 { -+ t.Fatal("expected IsAlive=false after host closed") -+ } -+} -+ -+// TestIsAlive_FalseForUnknownSession verifies IsAlive returns (false, nil) for -+// a session not in the map or registry. -+func TestIsAlive_FalseForUnknownSession(t *testing.T) { -+ isolateRegistry(t) -+ rt := New(Options{Spawner: fakeSpawnerFor(t, nil, livePID())}) -+ ctx := context.Background() -+ -+ alive, err := rt.IsAlive(ctx, ports.RuntimeHandle{ID: "ghost-session"}) -+ if err != nil { -+ t.Fatalf("IsAlive: unexpected error: %v", err) -+ } -+ if alive { -+ t.Fatal("expected IsAlive=false for unknown session") -+ } -+} -+ -+// TestDestroy_KillsHostAndCleansUp verifies Destroy triggers clientKill, -+// removes the map + registry entry, and is idempotent on second call. -+// Uses deadPID() so the force-kill step is a safe no-op (the fake pty-host -+// has no real OS process; clientKill already shut it down via the loopback). -+func TestDestroy_KillsHostAndCleansUp(t *testing.T) { -+ isolateRegistry(t) -+ hosts := map[string]*inProcHost{} -+ rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, deadPID())}) -+ ctx := context.Background() -+ -+ handle, err := rt.Create(ctx, ports.RuntimeConfig{ -+ SessionID: "sess-destroy", -+ WorkspacePath: "/tmp/w", -+ Argv: []string{"sh"}, -+ }) -+ if err != nil { -+ t.Fatalf("Create: %v", err) -+ } -+ h := hosts["sess-destroy"] -+ -+ // Destroy should succeed. -+ if err := rt.Destroy(ctx, handle); err != nil { -+ t.Fatalf("Destroy: %v", err) -+ } -+ -+ // Wait for Serve to stop (clientKill triggers shutdown). -+ select { -+ case <-h.done: -+ case <-time.After(3 * time.Second): -+ t.Fatal("host did not stop after Destroy") -+ } -+ -+ // fakePTY.Close must have been called. -+ h.pty.closeMu.Lock() -+ closed := h.pty.closed -+ h.pty.closeMu.Unlock() -+ if !closed { -+ t.Fatal("expected fakePTY.Close() after Destroy") -+ } -+ -+ // Map entry must be gone. -+ rt.mu.Lock() -+ _, exists := rt.sessions["sess-destroy"] -+ rt.mu.Unlock() -+ if exists { -+ t.Fatal("expected map entry removed after Destroy") -+ } -+ -+ // Registry entry must be gone. -+ entries, _ := ptyregistry.List() -+ for _, e := range entries { -+ if e.SessionID == "sess-destroy" { -+ t.Fatal("expected registry entry removed after Destroy") -+ } -+ } -+ -+ // Second Destroy must be idempotent (returns nil). -+ if err := rt.Destroy(ctx, handle); err != nil { -+ t.Fatalf("second Destroy: expected nil, got %v", err) -+ } -+} -+ -+// TestResolveViaRegistry verifies that with an empty in-memory map but a -+// registry entry pointing at a live in-process host, IsAlive and SendMessage -+// still work (simulates a daemon restart). -+func TestResolveViaRegistry(t *testing.T) { -+ isolateRegistry(t) -+ -+ // Start a host directly (not through Create) to simulate a pre-existing -+ // pty-host from a previous daemon run. Use the current process PID so -+ // ptyregistry.List() does not prune the entry as dead. -+ h := startInProcHost(t, "sess-reg", livePID()) -+ defer h.cleanup(t) -+ -+ // Manually register the host in the registry. -+ err := ptyregistry.Register(ptyregistry.Entry{ -+ SessionID: "sess-reg", -+ PtyHostPID: h.pid, -+ PipePath: h.addr, // addr stored in PipePath field -+ RegisteredAt: fmt.Sprintf("%d", time.Now().Unix()), -+ }) -+ if err != nil { -+ t.Fatalf("Register: %v", err) -+ } -+ -+ // Create a Runtime with an empty in-memory map (simulates daemon restart). -+ rt := New(Options{Spawner: fakeSpawnerFor(t, nil, livePID())}) -+ ctx := context.Background() -+ -+ // IsAlive must work via registry resolution. -+ alive, err := rt.IsAlive(ctx, ports.RuntimeHandle{ID: "sess-reg"}) -+ if err != nil { -+ t.Fatalf("IsAlive via registry: %v", err) -+ } -+ if !alive { -+ t.Fatal("expected IsAlive=true via registry resolution") -+ } -+ -+ // SendMessage must work via registry resolution. -+ inputC := make(chan []byte, 4) -+ go func() { -+ buf := make([]byte, 512) -+ for { -+ n, err := h.pty.inR.Read(buf) -+ if n > 0 { -+ cp := make([]byte, n) -+ copy(cp, buf[:n]) -+ inputC <- cp -+ } -+ if err != nil { -+ return -+ } -+ } -+ }() -+ -+ if err := rt.SendMessage(ctx, ports.RuntimeHandle{ID: "sess-reg"}, "ping"); err != nil { -+ t.Fatalf("SendMessage via registry: %v", err) -+ } -+ -+ // Collect PTY input. -+ var received []byte -+ deadline := time.After(3 * time.Second) -+ for !bytes.Contains(received, []byte("\r")) { -+ select { -+ case chunk := <-inputC: -+ received = append(received, chunk...) -+ case <-deadline: -+ t.Fatalf("timeout waiting for PTY input via registry; got %q", received) -+ } -+ } -+ if !bytes.Contains(received, []byte("ping")) { -+ t.Fatalf("PTY did not receive 'ping'; got %q", received) -+ } -+} -+ -+// --------------------------------------------------------------------------- -+// Unit tests for client helpers (dial a fresh in-proc host directly). -+// --------------------------------------------------------------------------- -+ -+// TestClientGetOutput_TimesOutReturnsEmpty verifies clientGetOutput returns "" -+// (no error) if no response arrives within the timeout. We test the happy path -+// instead (timeout path would require a non-responding server). -+func TestClientGetOutput_HappyPath(t *testing.T) { -+ f := startServe(t, 3001) -+ defer f.cancel() -+ -+ f.ring.Append([]byte("alpha\nbeta\ngamma\n")) -+ -+ text, err := clientGetOutput(f.addr, 2) -+ if err != nil { -+ t.Fatalf("clientGetOutput: %v", err) -+ } -+ want := f.ring.Tail(2) -+ if text != want { -+ t.Fatalf("clientGetOutput = %q, want %q", text, want) -+ } -+} -+ -+// TestClientIsAlive_TrueAndFalse verifies clientIsAlive returns true for a -+// live host and false for an unreachable address. -+func TestClientIsAlive_TrueAndFalse(t *testing.T) { -+ f := startServe(t, 3002) -+ defer f.cancel() -+ -+ if !clientIsAlive(f.addr) { -+ t.Fatal("clientIsAlive = false for live host, want true") -+ } -+ -+ f.cancel() -+ // Wait for listener to close. -+ select { -+ case <-f.done: -+ case <-time.After(2 * time.Second): -+ } -+ time.Sleep(50 * time.Millisecond) -+ -+ if clientIsAlive(f.addr) { -+ t.Fatal("clientIsAlive = true after host closed, want false") -+ } -+} -+ -+// TestClientKill_Idempotent verifies clientKill on a dead address returns nil. -+func TestClientKill_Idempotent(t *testing.T) { -+ if err := clientKill("127.0.0.1:1"); err != nil { -+ t.Fatalf("clientKill on unreachable addr: %v", err) -+ } -+} -+ -+// helper for TestSendMessage_LargeMessageChunked -+func min(a, b int) int { -+ if a < b { -+ return a -+ } -+ return b -+} -+ -+// Ensure the packages compile (import check). -+var _ = io.Discard -diff --git a/backend/internal/adapters/runtime/conpty/spawn.go b/backend/internal/adapters/runtime/conpty/spawn.go -new file mode 100644 -index 0000000..a32a996 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/spawn.go -@@ -0,0 +1,11 @@ -+// spawn.go - injectable hostSpawner seam. The real detached-process spawn is -+// Windows-only (spawn_windows.go). This file defines the type and the -+// defaultSpawnHost variable; the non-windows stub is in spawn_other.go. -+package conpty -+ -+import "context" -+ -+// hostSpawner starts a detached pty-host for the session and returns its -+// loopback address ("127.0.0.1:PORT") and OS pid once it prints READY. -+// Injectable for tests: replace this field on Options before calling New. -+type hostSpawner func(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (addr string, pid int, err error) -diff --git a/backend/internal/adapters/runtime/conpty/spawn_other.go b/backend/internal/adapters/runtime/conpty/spawn_other.go -new file mode 100644 -index 0000000..342836a ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/spawn_other.go -@@ -0,0 +1,16 @@ -+//go:build !windows -+ -+// spawn_other.go - stub for non-Windows platforms. The real detached-process -+// spawn lives in spawn_windows.go and uses Windows process-creation flags. -+package conpty -+ -+import ( -+ "context" -+ "errors" -+) -+ -+// defaultSpawnHost is a stub on non-Windows platforms. Tests inject their own -+// spawner; this only needs to keep the package buildable on Darwin/Linux. -+func defaultSpawnHost(_ context.Context, _, _ string, _ []string, _ map[string]string) (string, int, error) { -+ return "", 0, errors.New("conpty spawn: unsupported on this OS") -+} -diff --git a/backend/internal/adapters/runtime/conpty/spawn_windows.go b/backend/internal/adapters/runtime/conpty/spawn_windows.go -new file mode 100644 -index 0000000..e8de3d7 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/spawn_windows.go -@@ -0,0 +1,121 @@ -+//go:build windows -+ -+// spawn_windows.go - real detached pty-host spawner for Windows using -+// CREATE_NEW_PROCESS_GROUP + DETACHED_PROCESS so the host survives daemon exit. -+package conpty -+ -+import ( -+ "bufio" -+ "context" -+ "fmt" -+ "io" -+ "os" -+ "os/exec" -+ "regexp" -+ "strconv" -+ "strings" -+ "time" -+ -+ "golang.org/x/sys/windows" -+) -+ -+// readyRE matches the "READY: " line printed by RunHost. -+var readyRE = regexp.MustCompile(`READY:(\d+) (\d+)`) -+ -+const spawnReadyTimeout = 10 * time.Second -+ -+// defaultSpawnHost resolves the current executable, builds the pty-host argv, -+// and spawns it detached on Windows. It reads stdout for "READY: " -+// with a 10s timeout, then unrefs (detaches) the child. Returns the loopback -+// address and the pty-host OS PID. -+func defaultSpawnHost(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (string, int, error) { -+ exe, err := os.Executable() -+ if err != nil { -+ return "", 0, fmt.Errorf("conpty spawn: resolve executable: %w", err) -+ } -+ -+ // Build: pty-host -+ args := append([]string{"pty-host", sessionID, cwd}, argv...) -+ -+ // Merge env: inherit parent, then overlay caller-provided vars. -+ merged := os.Environ() -+ for k, v := range env { -+ merged = append(merged, k+"="+v) -+ } -+ -+ cmd := exec.CommandContext(ctx, exe, args...) -+ cmd.Dir = cwd -+ cmd.Env = merged -+ -+ // Windows process-creation flags: detached + hidden console. -+ // ponytail: DETACHED_PROCESS puts the child in its own console; without it -+ // the child is killed when the parent's console closes. CREATE_NEW_PROCESS_GROUP -+ // insulates it from Ctrl+C sent to the parent. windowsHide suppresses the flash. -+ cmd.SysProcAttr = &windows.SysProcAttr{ -+ CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP, -+ HideWindow: true, -+ } -+ -+ stdout, err := cmd.StdoutPipe() -+ if err != nil { -+ return "", 0, fmt.Errorf("conpty spawn: stdout pipe: %w", err) -+ } -+ // Stderr is discarded; pty-host writes diagnostics there but we don't need them. -+ cmd.Stderr = io.Discard -+ -+ if err := cmd.Start(); err != nil { -+ return "", 0, fmt.Errorf("conpty spawn: start: %w", err) -+ } -+ -+ // Read READY line with a timeout. -+ readyC := make(chan struct { -+ addr string -+ pid int -+ err error -+ }, 1) -+ -+ go func() { -+ scanner := bufio.NewScanner(stdout) -+ for scanner.Scan() { -+ line := strings.TrimSpace(scanner.Text()) -+ m := readyRE.FindStringSubmatch(line) -+ if m != nil { -+ pid, _ := strconv.Atoi(m[1]) -+ port, _ := strconv.Atoi(m[2]) -+ readyC <- struct { -+ addr string -+ pid int -+ err error -+ }{"127.0.0.1:" + strconv.Itoa(port), pid, nil} -+ return -+ } -+ } -+ readyC <- struct { -+ addr string -+ pid int -+ err error -+ }{"", 0, fmt.Errorf("conpty spawn: pty-host exited without printing READY")} -+ }() -+ -+ timer := time.NewTimer(spawnReadyTimeout) -+ defer timer.Stop() -+ -+ select { -+ case r := <-readyC: -+ if r.err != nil { -+ _ = cmd.Process.Kill() -+ return "", 0, r.err -+ } -+ // Unref: detach stdout so the child is not blocked, then release reference -+ // so our process can exit while the child keeps running. -+ stdout.Close() -+ cmd.Process.Release() // nolint: errcheck - best-effort detach -+ return r.addr, cmd.Process.Pid, nil -+ case <-timer.C: -+ _ = cmd.Process.Kill() -+ return "", 0, fmt.Errorf("conpty spawn: pty-host startup timeout (%s)", spawnReadyTimeout) -+ case <-ctx.Done(): -+ _ = cmd.Process.Kill() -+ return "", 0, ctx.Err() -+ } -+} - ---- Changes --- - -backend/internal/adapters/runtime/conpty/client.go - @@ -0,0 +1,179 @@ - +// client.go - loopback TCP client helpers that mirror pty-client.ts. - +// Each function dials the host addr fresh (short-lived connection) and - +// returns without maintaining state. Cross-platform: uses only stdlib net. - +package conpty - + - +import ( - + "encoding/json" - + "net" - + "time" - +) - + - +const ( - + // ptyInputChunkRunes is the max runes per terminal-input frame. - + // Mirrors PTY_INPUT_CHUNK_CHARS in pty-client.ts. - + ptyInputChunkRunes = 512 - + // ptyInputChunkDelay is the inter-chunk delay. Mirrors PTY_INPUT_CHUNK_DELAY_MS. - + ptyInputChunkDelay = 15 * time.Millisecond - + // ptyInputEnterDelay is the pause before sending Enter. Mirrors PTY_INPUT_ENTER_DELAY_MS. - + ptyInputEnterDelay = 300 * time.Millisecond - + - + dialTimeout = 3 * time.Second - + getOutputTimeout = 3 * time.Second - + isAliveTimeout = 2 * time.Second - +) - + - +// dialHost opens a TCP connection to addr with a deadline. Callers close it. - +func dialHost(addr string, timeout time.Duration) (net.Conn, error) { - + return net.DialTimeout("tcp", addr, timeout) - +} - + - +// clientSendMessage chunks message by 512 runes and sends each as a - +// MsgTerminalInput frame with 15ms gaps, then pauses 300ms and sends "\r". - +// Mirrors ptyHostSendMessage from pty-client.ts. - +func clientSendMessage(addr, message string) error { - + conn, err := dialHost(addr, dialTimeout) - + if err != nil { - + return err - + } - + defer conn.Close() - + - + runes := []rune(message) - + for i := 0; i < len(runes); i += ptyInputChunkRunes { - + end := i + ptyInputChunkRunes - + if end > len(runes) { - + end = len(runes) - + } - + chunk := string(runes[i:end]) - + frame := EncodeMessage(MsgTerminalInput, []byte(chunk)) - + if _, err := conn.Write(frame); err != nil { - + return err - + } - + // Inter-chunk delay only between chunks, not after the last one. - + if end < len(runes) { - + time.Sleep(ptyInputChunkDelay) - + } - + } - + - + // Brief pause before Enter (matches TS: Enter sent as a separate frame). - + time.Sleep(ptyInputEnterDelay) - + frame := EncodeMessage(MsgTerminalInput, []byte("\r")) - + _, err = conn.Write(frame) - + return err - +} - + - +// clientGetOutput sends MsgGetOutputReq and reads frames until MsgGetOutputRes. - +// Returns "" on timeout or connection failure (no error), matching the TS. - +// lines <= 0 is handled by the caller (runtime.go rejects it before calling). - +func clientGetOutput(addr string, lines int) (string, error) { - + conn, err := dialHost(addr, getOutputTimeout) - + if err != nil { - + return "", nil // ponytail: connect failure -> "" like the TS - + } - + defer conn.Close() - + - + _ = conn.SetDeadline(time.Now().Add(getOutputTimeout)) - + - + req, _ := json.Marshal(GetOutputReq{Lines: lines}) - + if _, err := conn.Write(EncodeMessage(MsgGetOutputReq, req)); err != nil { - + return "", nil - + } - + - + resultC := make(chan string, 1) - + parser := NewMessageParser(func(msgType byte, payload []byte) { - + if msgType == MsgGetOutputRes { - + select { - + case resultC <- string(payload): - + default: - + } - + } - + }) - + - + buf := make([]byte, 4096) - + for { - + n, err := conn.Read(buf) - + if n > 0 { - + parser.Feed(buf[:n]) - + } - + select { - + case text := <-resultC: - + return text, nil - ... (79 lines truncated) - +179 -0 - -backend/internal/adapters/runtime/conpty/pidalive_unix.go - @@ -0,0 +1,26 @@ - +//go:build !windows - + - +package conpty - + - +import ( - + "errors" - + "os" - + "syscall" - +) - + - +// pidAlive probes PID liveness via signal 0. nil and EPERM both mean alive - +// (process exists but may not be signallable). ESRCH means dead. - +// Mirrors ptyregistry.defaultPidAlive (same signal-0 pattern). - +func pidAlive(pid int) bool { - + err := syscall.Kill(pid, 0) - + if err == nil { - + return true - + } - + return errors.Is(err, syscall.EPERM) - +} - + - +// defaultOSProcessFinder wraps os.FindProcess for Unix (always succeeds on - +// Unix; the returned handle is valid for Kill). - +func defaultOSProcessFinder(pid int) (processKiller, error) { - + return os.FindProcess(pid) - +} - +26 -0 - -backend/internal/adapters/runtime/conpty/pidalive_windows.go - @@ -0,0 +1,30 @@ - +//go:build windows - + - +package conpty - + - +import ( - + "fmt" - + "os" - + - + "golang.org/x/sys/windows" - +) - + - +// pidAlive probes PID liveness on Windows by opening the process handle with - +// SYNCHRONIZE (minimal permission). Failure means the process is gone. - +func pidAlive(pid int) bool { - + h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) - + if err != nil { - + return false - + } - + _ = windows.CloseHandle(h) - + return true - +} - + - +// defaultOSProcessFinder wraps os.FindProcess for Windows. - +func defaultOSProcessFinder(pid int) (processKiller, error) { - + p, err := os.FindProcess(pid) - + if err != nil { - + return nil, fmt.Errorf("os.FindProcess(%d): %w", pid, err) - + } - + return p, nil - +} - +30 -0 - -backend/internal/adapters/runtime/conpty/runtime.go - @@ -0,0 +1,224 @@ - +// runtime.go - conpty Runtime adapter. Implements ports.Runtime (minus Attach, - +// which comes in B5). Drives sessions via the B3 pty-host over loopback TCP, - +// using the B1 protocol and the B2 registry for restart recovery. - +package conpty - + - +import ( - + "context" - + "fmt" - + "regexp" - + "sync" - + "time" - + - + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - +) - + - +// Ensure Runtime satisfies the port at compile time. Attach is added in B5. - +var _ ports.Runtime = (*Runtime)(nil) - + - +// validSessionID matches agent-orchestrator's assertValidSessionId. - +var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - + - +// hostSession is the in-memory state for a live pty-host connection. - +type hostSession struct { - + addr string - + pid int - +} - + - +// Options configures the Runtime. All fields are optional; zero values use - +// sensible defaults. The Spawner field is injectable for tests. - +type Options struct { - + // Spawner overrides the default OS-level process spawner. If nil, - + // defaultSpawnHost is used (Windows-only; returns an error on other OSes). - + Spawner hostSpawner - +} - + - +// Runtime is the conpty runtime adapter. - +type Runtime struct { - + spawner hostSpawner - + - + mu sync.Mutex - + sessions map[string]*hostSession // sessionID -> live session - +} - + - +// New creates a Runtime with the given options. - +func New(opts Options) *Runtime { - + sp := opts.Spawner - + if sp == nil { - + sp = defaultSpawnHost - + } - + return &Runtime{ - + spawner: sp, - + sessions: make(map[string]*hostSession), - + } - +} - + - +// Create spawns a detached pty-host for the session, waits for READY, stores - +// the addr+pid in-memory and in the B2 registry, and returns the handle. - +// Returns an error if sessionID is invalid, already exists, or spawn fails. - +func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { - + id := string(cfg.SessionID) - + if !validSessionID.MatchString(id) { - + return ports.RuntimeHandle{}, fmt.Errorf("conpty: invalid session id %q: must match ^[a-zA-Z0-9_-]+$", id) - + } - + if cfg.WorkspacePath == "" { - + return ports.RuntimeHandle{}, fmt.Errorf("conpty: workspace path required") - + } - + if len(cfg.Argv) == 0 { - + return ports.RuntimeHandle{}, fmt.Errorf("conpty: argv required") - + } - + - + r.mu.Lock() - + if _, dup := r.sessions[id]; dup { - + r.mu.Unlock() - + return ports.RuntimeHandle{}, fmt.Errorf("conpty: session %q already exists; destroy before re-creating", id) - + } - + // Reserve the slot before the async spawn so a concurrent Create for the - + // same id fails immediately (no gap between check and set). - + r.sessions[id] = nil - + r.mu.Unlock() - + - + addr, pid, err := r.spawner(ctx, id, cfg.WorkspacePath, cfg.Argv, cfg.Env) - + if err != nil { - + r.mu.Lock() - + delete(r.sessions, id) - + r.mu.Unlock() - + return ports.RuntimeHandle{}, fmt.Errorf("conpty: spawn pty-host for %q: %w", id, err) - + } - + - + sess := &hostSession{addr: addr, pid: pid} - + - + r.mu.Lock() - + r.sessions[id] = sess - + r.mu.Unlock() - + - + // Register in B2 registry for daemon-restart recovery (best-effort). - + _ = ptyregistry.Register(ptyregistry.Entry{ - + SessionID: id, - + PtyHostPID: pid, - + PipePath: addr, // ponytail: reuse PipePath field for loopback addr - ... (124 lines truncated) - +224 -0 - -backend/internal/adapters/runtime/conpty/runtime_test.go - @@ -0,0 +1,612 @@ - +package conpty - + - +import ( - + "bytes" - + "context" - + "fmt" - + "io" - + "net" - + "os" - + "strings" - + "testing" - + "time" - + - + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" - + "github.com/aoagents/agent-orchestrator/backend/internal/domain" - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - +) - + - +// livePID returns a PID that is guaranteed to be alive (the current process). - +// Using this as the fake pty-host PID means ptyregistry.List() will not prune - +// the entry during tests. Do NOT use this for the Destroy test: Destroy calls - +// Kill on the pid, so use deadPID() there instead. - +func livePID() int { return os.Getpid() } - + - +// deadPID returns a PID that is guaranteed to be dead (no process). This is - +// used in Destroy tests so the force-kill step is a safe no-op. - +// ponytail: PID 2147483647 (MaxInt32) is never a real process; signal-0 returns ESRCH. - +func deadPID() int { return 2147483647 } - + - +// --------------------------------------------------------------------------- - +// Test harness: in-process pty-host backed by a fakePTY. - +// --------------------------------------------------------------------------- - + - +// inProcHost starts a Serve engine with a fakePTY on a real 127.0.0.1:0 - +// listener and returns a fake spawner that returns that addr and a fake pid. - +// The caller must call cleanup() to shut down the host. - +type inProcHost struct { - + addr string - + pid int - + pty *fakePTY - + ring *Ring - + cancel context.CancelFunc - + done chan error - + ln net.Listener - +} - + - +func startInProcHost(t *testing.T, sessionID string, fakePID int) *inProcHost { - + t.Helper() - + ln, err := net.Listen("tcp", "127.0.0.1:0") - + if err != nil { - + t.Fatalf("listen: %v", err) - + } - + pty := newFakePTY(fakePID) - + ring := NewRing() - + ctx, cancel := context.WithCancel(context.Background()) - + done := make(chan error, 1) - + go func() { - + done <- Serve(ctx, ServeConfig{ - + SessionID: sessionID, - + Listener: ln, - + PTY: pty, - + Ring: ring, - + }) - + }() - + return &inProcHost{ - + addr: ln.Addr().String(), - + pid: fakePID, - + pty: pty, - + ring: ring, - + cancel: cancel, - + done: done, - + ln: ln, - + } - +} - + - +func (h *inProcHost) cleanup(t *testing.T) { - + t.Helper() - + h.cancel() - + select { - + case <-h.done: - + case <-time.After(2 * time.Second): - + t.Log("warning: inProcHost did not stop within 2s") - + } - +} - + - +// fakeSpawnerFor returns a hostSpawner that starts an in-process host for a - +// single session ID and records which sessions have been spawned. - +// The returned map maps sessionID -> *inProcHost for test inspection. - +func fakeSpawnerFor(t *testing.T, hosts map[string]*inProcHost, fakePID int) hostSpawner { - + t.Helper() - + return func(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (string, int, error) { - + h := startInProcHost(t, sessionID, fakePID) - + if hosts != nil { - + hosts[sessionID] = h - + } - + return h.addr, h.pid, nil - + } - +} - + - +// --------------------------------------------------------------------------- - ... (512 lines truncated) - +612 -0 - -backend/internal/adapters/runtime/conpty/spawn.go - @@ -0,0 +1,11 @@ - +// spawn.go - injectable hostSpawner seam. The real detached-process spawn is - +// Windows-only (spawn_windows.go). This file defines the type and the - +// defaultSpawnHost variable; the non-windows stub is in spawn_other.go. - +package conpty - + - +import "context" - + - +// hostSpawner starts a detached pty-host for the session and returns its - +// loopback address ("127.0.0.1:PORT") and OS pid once it prints READY. - +// Injectable for tests: replace this field on Options before calling New. - +type hostSpawner func(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (addr string, pid int, err error) - +11 -0 - -backend/internal/adapters/runtime/conpty/spawn_other.go - @@ -0,0 +1,16 @@ - +//go:build !windows - + - +// spawn_other.go - stub for non-Windows platforms. The real detached-process - +// spawn lives in spawn_windows.go and uses Windows process-creation flags. - +package conpty - + - +import ( - + "context" - + "errors" - +) - + - +// defaultSpawnHost is a stub on non-Windows platforms. Tests inject their own - +// spawner; this only needs to keep the package buildable on Darwin/Linux. - +func defaultSpawnHost(_ context.Context, _, _ string, _ []string, _ map[string]string) (string, int, error) { - + return "", 0, errors.New("conpty spawn: unsupported on this OS") - +} - +16 -0 - -backend/internal/adapters/runtime/conpty/spawn_windows.go - @@ -0,0 +1,121 @@ - +//go:build windows - + - +// spawn_windows.go - real detached pty-host spawner for Windows using - +// CREATE_NEW_PROCESS_GROUP + DETACHED_PROCESS so the host survives daemon exit. - +package conpty - + - +import ( - + "bufio" - + "context" - + "fmt" - + "io" - + "os" - + "os/exec" - + "regexp" - + "strconv" - + "strings" - + "time" - + - + "golang.org/x/sys/windows" - +) - + - +// readyRE matches the "READY: " line printed by RunHost. - +var readyRE = regexp.MustCompile(`READY:(\d+) (\d+)`) - + - +const spawnReadyTimeout = 10 * time.Second - + - +// defaultSpawnHost resolves the current executable, builds the pty-host argv, - +// and spawns it detached on Windows. It reads stdout for "READY: " - +// with a 10s timeout, then unrefs (detaches) the child. Returns the loopback - +// address and the pty-host OS PID. - +func defaultSpawnHost(ctx context.Context, sessionID, cwd string, argv []string, env map[string]string) (string, int, error) { - + exe, err := os.Executable() - + if err != nil { - + return "", 0, fmt.Errorf("conpty spawn: resolve executable: %w", err) - + } - + - + // Build: pty-host - + args := append([]string{"pty-host", sessionID, cwd}, argv...) - + - + // Merge env: inherit parent, then overlay caller-provided vars. - + merged := os.Environ() - + for k, v := range env { - + merged = append(merged, k+"="+v) - + } - + - + cmd := exec.CommandContext(ctx, exe, args...) - + cmd.Dir = cwd - + cmd.Env = merged - + - + // Windows process-creation flags: detached + hidden console. - + // ponytail: DETACHED_PROCESS puts the child in its own console; without it - + // the child is killed when the parent's console closes. CREATE_NEW_PROCESS_GROUP - + // insulates it from Ctrl+C sent to the parent. windowsHide suppresses the flash. - + cmd.SysProcAttr = &windows.SysProcAttr{ - + CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP, - + HideWindow: true, - + } - + - + stdout, err := cmd.StdoutPipe() - + if err != nil { - + return "", 0, fmt.Errorf("conpty spawn: stdout pipe: %w", err) - + } - + // Stderr is discarded; pty-host writes diagnostics there but we don't need them. - + cmd.Stderr = io.Discard - + - + if err := cmd.Start(); err != nil { - + return "", 0, fmt.Errorf("conpty spawn: start: %w", err) - + } - + - + // Read READY line with a timeout. - + readyC := make(chan struct { - + addr string - + pid int - + err error - + }, 1) - + - + go func() { - + scanner := bufio.NewScanner(stdout) - + for scanner.Scan() { - + line := strings.TrimSpace(scanner.Text()) - + m := readyRE.FindStringSubmatch(line) - + if m != nil { - + pid, _ := strconv.Atoi(m[1]) - + port, _ := strconv.Atoi(m[2]) - + readyC <- struct { - + addr string - + pid int - + err error - + }{"127.0.0.1:" + strconv.Itoa(port), pid, nil} - + return - + } - -... (more changes truncated) - +91 -0 -[full diff: rtk git diff --no-compact] diff --git a/.superpowers/sdd/task-b5-brief.md b/.superpowers/sdd/task-b5-brief.md deleted file mode 100644 index 9fdf7437..00000000 --- a/.superpowers/sdd/task-b5-brief.md +++ /dev/null @@ -1,159 +0,0 @@ -# Task B5: terminal Attach/Stream interface change + tmux/zellij/conpty Attach - -## Goal -Evolve the terminal layer from argv-based attach (`PTYSource.AttachCommand` + a -`spawnFunc`) to stream-based attach (`Attacher.Attach(...) Stream`), so the conpty -runtime can attach by dialing its loopback host directly (no argv to exec) while -tmux/zellij keep spawning their attach CLI under the hood. This is the keystone that -makes conpty usable from the dashboard. It is fully testable on this Darwin machine -(terminal unit tests + tmux integration). Keep the build green and ALL tests -passing. This is a refactor of working code: be surgical and preserve behavior on -the tmux/zellij paths exactly. - -Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, -module `backend/`, branch `migrate-zellij-to-tmux-conpty`. Module prefix -`github.com/aoagents/agent-orchestrator/backend`. - -## Read first (the code you are changing) -- `internal/terminal/attachment.go` (the run loop: IsAlive gate -> AttachCommand -> - size -> spawn -> copyOut -> reattach), `manager.go` (spawn/WithSpawn plumbing, - openTerminal), `pty_unix.go` + `pty_windows.go` (defaultSpawn + creackPTY / - conPTYProcess), `fakes_test.go` (fakeSource, fakeSpawner), `attachment_test.go`, - `manager_test.go`, `attachment_integration_test.go`, `logger_test.go`, - `pty_unix_test.go`, `pty_windows_test.go`. -- `internal/ports/outbound.go` (Runtime, RuntimeHandle). -- `internal/adapters/runtime/tmux/` (AttachCommand), `.../zellij/` (AttachCommand), - `.../conpty/` (client.go dial + proto), `.../runtimeselect/select.go` (the union). - -## Step 1 — define Stream + Attacher in `ports` (`internal/ports/outbound.go`) -```go -// Stream is one live terminal attach: PTY-like bytes plus resize. Returned -// already-open by a Runtime's Attach. tmux/zellij back it with a local PTY around -// their attach CLI; conpty backs it with a loopback connection to the pty-host. -type Stream interface { - io.ReadWriteCloser - Resize(rows, cols uint16) error -} - -// Attacher opens a fresh attach Stream for a session handle, sized rows x cols from -// birth (0 means size not yet known). ctx cancellation must terminate the stream. -type Attacher interface { - Attach(ctx context.Context, handle RuntimeHandle, rows, cols uint16) (Stream, error) -} -``` -(`io` import added to ports.) Do NOT remove `Runtime`/`RuntimeHandle`. - -## Step 2 — extract the PTY spawn into a shared package `internal/adapters/runtime/ptyexec` -Move the existing `defaultSpawn` + `creackPTY` (from terminal/pty_unix.go) and the -ConPTY `conPTYProcess` (from terminal/pty_windows.go) into a new package `ptyexec`, -exported as: -```go -// Spawn starts argv on a local PTY (creack/pty on unix, go-pty ConPTY on windows), -// sized rows x cols from birth when known. env, when non-nil, replaces the inherited -// environment. ctx cancellation closes the PTY via the same graceful path as Close. -func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) -``` -- Preserve EVERY behavior and comment from the originals: StartWithSize, the - SIGWINCH-after-Setsize self-heal on unix, the SIGTERM->grace->SIGKILL detach on - unix (`detachGrace`), the ConPTY EOF-then-Kill close on windows. The returned - concrete types must satisfy `ports.Stream` (they already have Read/Write/Close + - Resize). Keep the unix/windows build-tag split (`spawn_unix.go`/`spawn_windows.go`). -- Move pty_unix_test.go / pty_windows_test.go into ptyexec as well (adjust package + - any unexported references). These tests must still pass. -- The terminal package no longer needs defaultSpawn after Step 3; delete the moved - files from terminal. - -## Step 3 — rewire the terminal layer to Attach (`attachment.go`, `manager.go`) -- Replace `PTYSource` with: -```go -// Source is what the terminal needs from the runtime: open an attach Stream and a -// liveness check used to decide whether a dropped Stream should be re-attached or -// treated as a clean exit. -type Source interface { - ports.Attacher - IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) -} -``` - (Rename PTYSource -> Source throughout terminal, or keep the name PTYSource but - change its methods; pick one and be consistent. Update doc comments that describe - "argv".) -- In `attachment`: delete the `spawn spawnFunc` field and the `spawnFunc` type. - Change the run loop: after the IsAlive gate, do - `rows, cols := a.size(); p, err := a.src.Attach(ctx, a.handle, rows, cols)` - instead of AttachCommand + spawn. `p` is a `ports.Stream`; the rest of the loop - (setPTY/copyOut/clearPTY/Close/reattach/backoff) is UNCHANGED. The local - `ptyProcess` interface in attachment.go can be replaced by `ports.Stream` (same - shape) or kept as an alias; prefer using `ports.Stream` directly. - Keep the failure handling: an Attach error increments failures and backs off (same - as the old spawn error path); a definitive `!alive` still markExited. -- In `manager.go`: delete `spawn spawnFunc`, the `defaultSpawn` default, and - `WithSpawn`. `newAttachment` loses its spawn parameter. `NewManager(src, events, - log, opts...)` keeps `src` (now a `Source`). If tests need to inject a fake, they - inject it via `src` (already the first arg) — so `WithSpawn` is removed and tests - pass a fake `Source` whose `Attach` returns a fake `Stream`. - -## Step 4 — implement Attach on the three adapters -- **tmux** (`internal/adapters/runtime/tmux`): add - `func (r *Runtime) Attach(ctx, handle, rows, cols) (ports.Stream, error)`: - reuse the existing argv builder (the body of AttachCommand: `tmux attach-session - -t `), then `return ptyexec.Spawn(ctx, argv, nil, rows, cols)`. You MAY keep - AttachCommand as an unexported helper that builds the argv, or inline it. Add - `var _ ports.Attacher = (*Runtime)(nil)`. Keep AttachCommand removed from the - public surface only if nothing else uses it (the CLI/doctor do not). -- **zellij** (`internal/adapters/runtime/zellij`): same pattern. zellij's - AttachCommand returns argv AND an env block (Windows ConPTY socket dir); pass both - to `ptyexec.Spawn(ctx, argv, env, rows, cols)`. Add the `var _ ports.Attacher` - assertion. (zellij stays in the tree; Windows still builds it until B6.) -- **conpty** (`internal/adapters/runtime/conpty`): add - `func (r *Runtime) Attach(ctx, handle, rows, cols) (ports.Stream, error)`: - resolve the session addr (the existing resolve()), `dialHost(addr, ...)`, send an - initial MsgResize if rows/cols > 0, and return a `*loopbackStream` that: - - runs a goroutine reading frames via a B1 MessageParser; for each MsgTerminalData - it writes the payload into an internal io.Pipe; `Read` drains that pipe. (The - host sends the scrollback Snapshot as the first MsgTerminalData on connect, so - Read naturally yields the replay first.) - - `Write(p)` sends `EncodeMessage(MsgTerminalInput, p)` to the conn. - - `Resize(rows, cols)` sends `EncodeMessage(MsgResize, json{cols,rows})`. - - `Close()` closes the conn and the pipe; ctx cancellation also closes it. - Add `var _ ports.Attacher = (*Runtime)(nil)`. This is fully testable on Darwin - against an in-process B3 Serve + fakePTY: connect, assert the scrollback replay - arrives on Read, Write reaches the fakePTY input, Resize reaches fakePTY.Resize. - -## Step 5 — update the runtimeselect union (`internal/adapters/runtime/runtimeselect/select.go`) -Replace `AttachCommand(handle) ([]string, []string, error)` in the union `Runtime` -interface with `ports.Attacher` (i.e. `Attach(...)`). Keep the compile-time -assertions for tmux and zellij; they now also assert Attach. The daemon wiring -(terminal.NewManager(runtimeAdapter, ...)) still compiles because the union -satisfies terminal.Source. - -## Step 6 — migrate the terminal tests -- `fakes_test.go`: change `fakeSource` to implement `Attach(ctx, handle, rows, cols) - (ports.Stream, error)` (returning a scripted fake Stream) + IsAlive. Replace - `fakeSpawner`/fake spawn with a fake `Stream` (in-memory Read/Write/Resize/Close, - scriptable like the old fake PTY). Preserve the existing `alive/aliveErr/attachErr` - knobs (attachErr now makes Attach fail). -- `attachment_test.go`, `manager_test.go`, `logger_test.go`, - `attachment_integration_test.go`: update `newAttachment(...)`/`NewManager(...)` - call sites to drop the spawn arg / WithSpawn, and to use the fake Source's Attach. - The integration test (which used the real defaultSpawn + a real runtime) should now - drive a real adapter's Attach (tmux is available; or keep it pointed at whatever - runtime it used, adapted to Attach). Keep every assertion's intent. Do not weaken - tests to pass; if a test asserted argv contents from AttachCommand, re-target it to - assert the adapter's argv via the adapter's own (possibly unexported) builder or - drop only that now-meaningless assertion, noting why. - -## Definition of done (from `backend/`) -- `go build ./...` ; `GOOS=windows go build ./...` ; `GOOS=linux go build ./...` succeed. -- `go test -race ./...` passes (the WHOLE backend suite, not just the changed pkgs). -- `go vet ./...` clean. -- tmux integration test still passes against real tmux 3.6b (the Darwin attach path - must still actually work end to end). - -## Hard rules -- This refactors verified, working code (the tmux attach path shipped in Phase A). - Preserve its behavior exactly; the only structural change is argv->Stream. Do not - alter the reattach/backoff/size/SIGWINCH/detach semantics. -- No new go.mod module. Never use em dashes ("—"). Minimal/idiomatic; mark shortcuts - with `ponytail:`. Update stale "Zellij"/"argv" comments in the terminal files you - touch to be runtime-neutral. -- Commit on the branch; trailer `Co-Authored-By: Claude Opus 4.8 `. diff --git a/.superpowers/sdd/task-b5-report.md b/.superpowers/sdd/task-b5-report.md deleted file mode 100644 index 30826f81..00000000 --- a/.superpowers/sdd/task-b5-report.md +++ /dev/null @@ -1,146 +0,0 @@ -# Task B5 Report: terminal Attach/Stream interface change - -Status: DONE - -## Summary -Evolved the terminal layer from argv-based attach (`PTYSource.AttachCommand` + -injected `spawnFunc`) to stream-based attach (`Source` embedding -`ports.Attacher`). tmux/zellij keep spawning their attach CLI on a local PTY -(now via the shared `ptyexec.Spawn`); conpty attaches by dialing its loopback -pty-host directly. Behavior on the tmux/zellij paths is preserved exactly. - -## Interface shapes - -`internal/ports/outbound.go` (added `io` import): -```go -type Stream interface { - io.ReadWriteCloser - Resize(rows, cols uint16) error -} -type Attacher interface { - Attach(ctx context.Context, handle RuntimeHandle, rows, cols uint16) (Stream, error) -} -``` - -`internal/terminal/attachment.go`: -```go -type Source interface { - ports.Attacher - IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) -} -``` -`Runtime`/`RuntimeHandle` untouched. - -## Files changed / moved - -### New shared package `internal/adapters/runtime/ptyexec` -- `spawn_unix.go` — `Spawn(ctx, argv, env, rows, cols) (ports.Stream, error)`, - the verbatim creack/pty path: StartWithSize, the Setsize+explicit-SIGWINCH - self-heal, the SIGTERM->detachGrace->SIGKILL idempotent Close, ctx-cancel - closes the PTY. Concrete type `creackPTY` satisfies `ports.Stream`. -- `spawn_windows.go` — verbatim ConPTY path (`conPTYProcess`): Resize-on-birth, - EOF-then-Kill Close. Same `Spawn` signature, build-tag split preserved. -- `spawn_unix_test.go` / `spawn_windows_test.go` — moved from terminal, - re-pointed at `Spawn`/`detachGrace`/`ports.Stream`. Generic "attach client" - wording replaces "zellij" in the moved comments. (Windows test renamed - `TestDefaultSpawnWindows*` -> `TestSpawnWindows*`.) - -### Deleted from terminal -`pty_unix.go`, `pty_windows.go`, `pty_unix_test.go`, `pty_windows_test.go` -(moved into ptyexec). - -### `internal/ports/outbound.go` -Added `Stream` + `Attacher` + `io` import. - -### `internal/terminal/attachment.go` -- `PTYSource` -> `Source` (embeds `ports.Attacher` + IsAlive). -- Deleted the `ptyProcess` interface and the `spawnFunc` type; the run loop and - helpers (`copyOut`/`setPTY`/`clearPTY`) now use `ports.Stream` directly. -- Run loop: dropped the `AttachCommand` step; after the IsAlive gate it does - `rows, cols := a.size(); p, err := a.src.Attach(ctx, a.handle, rows, cols)`. - Reattach/backoff/size/markExited semantics are byte-for-byte identical. An - Attach error now shares the spawn-error retry policy (increment failures, back - off, cap at maxReattach) — the old immediate-fail on AttachCommand error is - gone because there is no separate argv-build step anymore. -- Stale "Zellij"/"argv" comments made runtime-neutral. - -### `internal/terminal/manager.go` -Removed `spawn spawnFunc` field, the `defaultSpawn` default, and `WithSpawn`. -`newAttachment` lost its spawn parameter. `src` is now a `Source`. Comments made -runtime-neutral. - -### tmux (`internal/adapters/runtime/tmux/tmux.go`) -`AttachCommand` -> unexported `attachCommand` (argv builder unchanged); new -`Attach(ctx, handle, rows, cols)` = `ptyexec.Spawn(ctx, argv, nil, rows, cols)`. -Added `var _ ports.Attacher = (*Runtime)(nil)` and the ptyexec import. - -### zellij (`internal/adapters/runtime/zellij/zellij.go`) -Same pattern; `attachCommand` returns argv AND the Windows env block, both passed -to `ptyexec.Spawn`. Added the `ports.Attacher` assertion + ptyexec import. - -### conpty (`internal/adapters/runtime/conpty/attach.go`, new) -`Attach` resolves the session addr (existing `resolve()`), `dialHost`, sends an -initial `MsgResize` when rows/cols > 0, and returns a `*loopbackStream`: -- a `pump` goroutine feeds a `MessageParser`; each `MsgTerminalData` payload is - written into an `io.Pipe` (the host sends the scrollback Snapshot as the first - such frame, so `Read` yields the replay first). Pipe back-pressure preserves - order; conn EOF closes the pipe with the error. -- `Write(p)` -> `EncodeMessage(MsgTerminalInput, p)` on the conn. -- `Resize(rows, cols)` -> `EncodeMessage(MsgResize, json{cols,rows})`. -- `Close()` (sync.Once) closes conn + both pipe ends; a ctx goroutine calls it on - cancel. -`runtime.go` header/assertion comments de-staled ("Attach in attach.go"). - -### runtimeselect (`runtimeselect.go`) -Union `Runtime` now embeds `ports.Attacher` in place of the `AttachCommand` -method; tmux + zellij compile-time assertions still hold (they now also assert -Attach). Daemon wiring `terminal.NewManager(runtimeAdapter, ...)` still compiles -because the union satisfies `terminal.Source`. - -## Test migration - -- `fakes_test.go`: `fakeSource` now implements `Attach` (delegating to an - embedded `*fakeSpawner` or a custom `attachFn` closure) + IsAlive; the - `alive/aliveErr/attachErr` knobs are preserved (`attachErr` makes Attach fail). - `fakeSpawner.spawn` lost its argv/env/ctx params (now `spawn(rows, cols) - (ports.Stream, error)`); `fakePTY` is a `ports.Stream`. The `argv` knob was - dropped (argv is no longer surfaced to the terminal layer). -- `attachment_test.go`: helpers lost the spawn arg; each test sets - `src.spawner = sp` (or `src.attachFn = ...`) instead of passing `sp.spawn`. - `TestAttachmentFailsWhenAttachCommandErrors` -> `TestAttachmentFailsWhenAttachErrors`: - retargeted to assert a persistent Attach error backs off and exits after the - retry budget (cap lowered to keep it fast), since attach errors now share the - spawn-error retry path. Every other assertion's intent preserved; "spawns" - wording -> "attaches". -- `manager_test.go`: dropped `WithSpawn`; fakes wired via `src.spawner`/`attachFn`. - Added the `ports` import. zellij-specific comments made runtime-neutral. -- `logger_test.go`: dropped the spawn arg from `newAttachment`/`NewManager`. -- `attachment_integration_test.go`: still drives the REAL zellij runtime, now via - its `Attach` (dropped the explicit `defaultSpawn` arg — `*zellij.Runtime` - satisfies `terminal.Source`). Alt-screen + stty-size assertions unchanged. -- `httpd/terminal_mux_test.go`: `stubSource.AttachCommand` -> `Attach` calling - `ptyexec.Spawn` (still exercises the genuine creack/pty + wsjson + Serve flow - on Darwin). -- New `conpty/attach_test.go`: drives `Attach` against an in-process B3 `Serve` + - `fakePTY` (reusing host_test.go's `serveFixture`): scrollback replay arrives on - Read; Write reaches `fakePTY.inR`; birth + explicit Resize reach - `fakePTY.Resize`; unknown session errors. 4 tests. - -## Verification (from backend/) -- `go build ./...` — Success -- `GOOS=windows go build ./...` — Success -- `GOOS=linux go build ./...` — Success -- `go vet ./...` — No issues found -- `go test -race ./...` — 1638 passed in 78 packages, 0 failures -- Real integration (not skipped, ran under -race): tmux 3.6b - `TestRuntimeIntegration` + `TestRuntimeIntegrationExactSessionParsing` pass; - zellij-backed `TestAttachmentStreamsRealZellijPane` + - `TestAttachmentReattachAdoptsNewSize` pass (the Darwin attach path works end to - end through the new `Attach`). - -## Concerns -None blocking. One intentional behavior note: a failed Attach is now treated as a -transient/retryable failure (back off + cap) rather than an immediate fail, -because the argv build is folded into Attach. This is the correct semantics for a -dial/exec failure and matches the old spawn-error path; the one test asserting -the old immediate-fail was retargeted accordingly. diff --git a/.superpowers/sdd/task-b5-review-package.txt b/.superpowers/sdd/task-b5-review-package.txt deleted file mode 100644 index fcf8de67..00000000 --- a/.superpowers/sdd/task-b5-review-package.txt +++ /dev/null @@ -1,3071 +0,0 @@ -=== COMMITS === -5bfbc48 feat(terminal): stream-based Attach for tmux/zellij/conpty - -=== STAT === -backend/internal/adapters/runtime/conpty/attach.go | 116 ++++++++++++++++ - .../adapters/runtime/conpty/attach_test.go | 151 +++++++++++++++++++++ - .../internal/adapters/runtime/conpty/runtime.go | 8 +- - .../runtime/ptyexec/spawn_unix.go} | 37 ++--- - .../runtime/ptyexec/spawn_unix_test.go} | 18 +-- - .../runtime/ptyexec/spawn_windows.go} | 21 +-- - .../runtime/ptyexec/spawn_windows_test.go} | 20 +-- - .../runtime/runtimeselect/runtimeselect.go | 5 +- - backend/internal/adapters/runtime/tmux/tmux.go | 17 ++- - .../internal/adapters/runtime/tmux/tmux_test.go | 4 +- - backend/internal/adapters/runtime/zellij/zellij.go | 18 ++- - .../adapters/runtime/zellij/zellij_test.go | 4 +- - backend/internal/httpd/terminal_mux_test.go | 13 +- - backend/internal/ports/outbound.go | 15 ++ - backend/internal/terminal/attachment.go | 98 +++++-------- - .../terminal/attachment_integration_test.go | 4 +- - backend/internal/terminal/attachment_test.go | 112 +++++++-------- - backend/internal/terminal/fakes_test.go | 29 ++-- - backend/internal/terminal/logger_test.go | 4 +- - backend/internal/terminal/manager.go | 25 ++-- - backend/internal/terminal/manager_test.go | 54 ++++---- - 21 files changed, 531 insertions(+), 242 deletions(-) -=== DIFF (backend) === -backend/internal/adapters/runtime/conpty/attach.go | 116 ++++++++++++++++ - .../adapters/runtime/conpty/attach_test.go | 151 +++++++++++++++++++++ - .../internal/adapters/runtime/conpty/runtime.go | 8 +- - .../runtime/ptyexec/spawn_unix.go} | 37 ++--- - .../runtime/ptyexec/spawn_unix_test.go} | 18 +-- - .../runtime/ptyexec/spawn_windows.go} | 21 +-- - .../runtime/ptyexec/spawn_windows_test.go} | 20 +-- - .../runtime/runtimeselect/runtimeselect.go | 5 +- - backend/internal/adapters/runtime/tmux/tmux.go | 17 ++- - .../internal/adapters/runtime/tmux/tmux_test.go | 4 +- - backend/internal/adapters/runtime/zellij/zellij.go | 18 ++- - .../adapters/runtime/zellij/zellij_test.go | 4 +- - backend/internal/httpd/terminal_mux_test.go | 13 +- - backend/internal/ports/outbound.go | 15 ++ - backend/internal/terminal/attachment.go | 98 +++++-------- - .../terminal/attachment_integration_test.go | 4 +- - backend/internal/terminal/attachment_test.go | 112 +++++++-------- - backend/internal/terminal/fakes_test.go | 29 ++-- - backend/internal/terminal/logger_test.go | 4 +- - backend/internal/terminal/manager.go | 25 ++-- - backend/internal/terminal/manager_test.go | 54 ++++---- - 21 files changed, 531 insertions(+), 242 deletions(-) - -diff --git a/backend/internal/adapters/runtime/conpty/attach.go b/backend/internal/adapters/runtime/conpty/attach.go -new file mode 100644 -index 0000000..c2e8363 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/attach.go -@@ -0,0 +1,116 @@ -+// attach.go - conpty Attach: a loopback Stream over the B3 pty-host. Unlike -+// tmux/zellij, conpty does not spawn an attach CLI; it dials the session's -+// loopback host and speaks the B1 framing protocol directly. The host replays -+// the scrollback Snapshot as the first MsgTerminalData on connect, so a fresh -+// Read naturally yields the repaint first. -+package conpty -+ -+import ( -+ "context" -+ "encoding/json" -+ "fmt" -+ "io" -+ "sync" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" -+) -+ -+var _ ports.Attacher = (*Runtime)(nil) -+ -+// Attach opens a fresh attach Stream for the session by dialing its loopback -+// pty-host. rows/cols size the host's PTY from birth when known (a MsgResize is -+// sent right after connect). ctx cancellation closes the Stream. -+func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { -+ sess := r.resolve(handle.ID) -+ if sess == nil { -+ return nil, fmt.Errorf("conpty: session %q not found", handle.ID) -+ } -+ conn, err := dialHost(sess.addr, dialTimeout) -+ if err != nil { -+ return nil, fmt.Errorf("conpty: dial host for %q: %w", handle.ID, err) -+ } -+ -+ pr, pw := io.Pipe() -+ s := &loopbackStream{conn: conn, pr: pr, pw: pw} -+ -+ // Pump host frames: MsgTerminalData payloads go into the pipe that Read -+ // drains. The first such frame is the scrollback snapshot, so the replay -+ // arrives before any live output. -+ go s.pump() -+ -+ // ctx cancellation must terminate the stream (mirrors the unix/windows -+ // spawn paths closing the PTY on ctx.Done). -+ go func() { -+ <-ctx.Done() -+ _ = s.Close() -+ }() -+ -+ if rows > 0 && cols > 0 { -+ if err := s.Resize(rows, cols); err != nil { -+ _ = s.Close() -+ return nil, err -+ } -+ } -+ return s, nil -+} -+ -+// loopbackStream is a ports.Stream backed by a single loopback connection to the -+// pty-host. The pump goroutine reframes host output into an io.Pipe so Read -+// presents a plain byte stream; Write/Resize encode client frames onto the conn. -+type loopbackStream struct { -+ conn io.ReadWriteCloser -+ pr *io.PipeReader -+ pw *io.PipeWriter -+ -+ closeOnce sync.Once -+} -+ -+// pump reads framed host messages and writes MsgTerminalData payloads into the -+// pipe. It closes the pipe when the connection ends so Read returns EOF. -+func (s *loopbackStream) pump() { -+ parser := NewMessageParser(func(msgType byte, payload []byte) { -+ if msgType == MsgTerminalData { -+ // Write blocks until Read drains, preserving back-pressure and order. -+ _, _ = s.pw.Write(payload) -+ } -+ }) -+ buf := make([]byte, 4096) -+ for { -+ n, err := s.conn.Read(buf) -+ if n > 0 { -+ parser.Feed(buf[:n]) -+ } -+ if err != nil { -+ _ = s.pw.CloseWithError(err) -+ return -+ } -+ } -+} -+ -+func (s *loopbackStream) Read(p []byte) (int, error) { return s.pr.Read(p) } -+ -+func (s *loopbackStream) Write(p []byte) (int, error) { -+ if _, err := s.conn.Write(EncodeMessage(MsgTerminalInput, p)); err != nil { -+ return 0, err -+ } -+ return len(p), nil -+} -+ -+func (s *loopbackStream) Resize(rows, cols uint16) error { -+ payload, _ := json.Marshal(ResizePayload{Cols: int(cols), Rows: int(rows)}) -+ _, err := s.conn.Write(EncodeMessage(MsgResize, payload)) -+ return err -+} -+ -+// Close closes the conn and the pipe. Idempotent. Closing the conn unblocks -+// pump's Read, which then closes the pipe-writer too; closing both here makes -+// Close safe to call directly (e.g. on ctx cancel) without waiting for pump. -+func (s *loopbackStream) Close() error { -+ var err error -+ s.closeOnce.Do(func() { -+ err = s.conn.Close() -+ _ = s.pw.Close() -+ _ = s.pr.Close() -+ }) -+ return err -+} -diff --git a/backend/internal/adapters/runtime/conpty/attach_test.go b/backend/internal/adapters/runtime/conpty/attach_test.go -new file mode 100644 -index 0000000..08b7ff8 ---- /dev/null -+++ b/backend/internal/adapters/runtime/conpty/attach_test.go -@@ -0,0 +1,151 @@ -+package conpty -+ -+import ( -+ "bytes" -+ "context" -+ "io" -+ "testing" -+ "time" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" -+) -+ -+// runtimeForFixture wires a conpty Runtime to a running serveFixture by stuffing -+// the fixture's loopback addr into the session map under the given id, so Attach -+// resolves it without a real Windows spawn. -+func runtimeForFixture(id string, f *serveFixture) *Runtime { -+ r := New(Options{}) -+ r.mu.Lock() -+ r.sessions[id] = &hostSession{addr: f.addr, pid: f.pty.PID()} -+ r.mu.Unlock() -+ return r -+} -+ -+func readUntil(t *testing.T, s io.Reader, want string, timeout time.Duration) string { -+ t.Helper() -+ type res struct { -+ out string -+ } -+ done := make(chan res, 1) -+ go func() { -+ var buf []byte -+ tmp := make([]byte, 4096) -+ for { -+ n, err := s.Read(tmp) -+ if n > 0 { -+ buf = append(buf, tmp[:n]...) -+ if bytes.Contains(buf, []byte(want)) { -+ done <- res{string(buf)} -+ return -+ } -+ } -+ if err != nil { -+ done <- res{string(buf)} -+ return -+ } -+ } -+ }() -+ select { -+ case r := <-done: -+ return r.out -+ case <-time.After(timeout): -+ t.Fatalf("timed out reading for %q", want) -+ return "" -+ } -+} -+ -+// TestAttachReplaysScrollback: the host sends the ring snapshot as the first -+// MsgTerminalData on connect, so a fresh Read on the Stream yields the replay. -+func TestAttachReplaysScrollback(t *testing.T) { -+ f := startServe(t, 300) -+ defer f.cancel() -+ f.ring.Append([]byte("scrollback-line\n")) -+ -+ r := runtimeForFixture("sess", f) -+ s, err := r.Attach(context.Background(), nameHandle("sess"), 0, 0) -+ if err != nil { -+ t.Fatalf("Attach: %v", err) -+ } -+ defer s.Close() -+ -+ out := readUntil(t, s, "scrollback-line", 2*time.Second) -+ if !bytes.Contains([]byte(out), []byte("scrollback-line")) { -+ t.Fatalf("scrollback not replayed on Read; got %q", out) -+ } -+} -+ -+// TestAttachWriteReachesPTY: Write on the Stream sends MsgTerminalInput, which -+// the host forwards to the fakePTY's input. -+func TestAttachWriteReachesPTY(t *testing.T) { -+ f := startServe(t, 301) -+ defer f.cancel() -+ -+ r := runtimeForFixture("sess", f) -+ s, err := r.Attach(context.Background(), nameHandle("sess"), 0, 0) -+ if err != nil { -+ t.Fatalf("Attach: %v", err) -+ } -+ defer s.Close() -+ -+ keystrokes := []byte("ls -la\r") -+ if _, err := s.Write(keystrokes); err != nil { -+ t.Fatalf("Write: %v", err) -+ } -+ buf := make([]byte, len(keystrokes)) -+ if _, err := io.ReadFull(f.pty.inR, buf); err != nil { -+ t.Fatalf("read pty input: %v", err) -+ } -+ if string(buf) != string(keystrokes) { -+ t.Fatalf("pty input = %q, want %q", buf, keystrokes) -+ } -+} -+ -+// TestAttachResizeReachesPTY: an initial size on Attach plus a later Resize both -+// reach the fakePTY.Resize via MsgResize frames. -+func TestAttachResizeReachesPTY(t *testing.T) { -+ f := startServe(t, 302) -+ defer f.cancel() -+ -+ r := runtimeForFixture("sess", f) -+ // Attach with a birth size: the implementation sends an initial MsgResize. -+ s, err := r.Attach(context.Background(), nameHandle("sess"), 40, 132) -+ if err != nil { -+ t.Fatalf("Attach: %v", err) -+ } -+ defer s.Close() -+ -+ if err := s.Resize(50, 160); err != nil { -+ t.Fatalf("Resize: %v", err) -+ } -+ -+ // Poll for both resizes (birth + explicit) to arrive on the fakePTY. -+ deadline := time.Now().Add(2 * time.Second) -+ for time.Now().Before(deadline) { -+ f.pty.resizeMu.Lock() -+ n := len(f.pty.resizes) -+ var last ResizePayload -+ if n > 0 { -+ last = f.pty.resizes[n-1] -+ } -+ f.pty.resizeMu.Unlock() -+ if n >= 2 && last.Cols == 160 && last.Rows == 50 { -+ return -+ } -+ time.Sleep(10 * time.Millisecond) -+ } -+ f.pty.resizeMu.Lock() -+ defer f.pty.resizeMu.Unlock() -+ t.Fatalf("resizes did not reach pty as expected: %+v", f.pty.resizes) -+} -+ -+// TestAttachUnknownSession: Attach to a session with no resolvable addr errors. -+func TestAttachUnknownSession(t *testing.T) { -+ r := New(Options{}) -+ if _, err := r.Attach(context.Background(), nameHandle("nope"), 0, 0); err == nil { -+ t.Fatal("expected error attaching to unknown session") -+ } -+} -+ -+func nameHandle(id string) ports.RuntimeHandle { -+ return ports.RuntimeHandle{ID: id} -+} -diff --git a/backend/internal/adapters/runtime/conpty/runtime.go b/backend/internal/adapters/runtime/conpty/runtime.go -index 818967b..efedbe8 100644 ---- a/backend/internal/adapters/runtime/conpty/runtime.go -+++ b/backend/internal/adapters/runtime/conpty/runtime.go -@@ -1,27 +1,27 @@ --// runtime.go - conpty Runtime adapter. Implements ports.Runtime (minus Attach, --// which comes in B5). Drives sessions via the B3 pty-host over loopback TCP, --// using the B1 protocol and the B2 registry for restart recovery. -+// runtime.go - conpty Runtime adapter. Implements ports.Runtime and -+// ports.Attacher (see attach.go). Drives sessions via the B3 pty-host over -+// loopback TCP, using the B1 protocol and the B2 registry for restart recovery. - package conpty - - import ( - "context" - "fmt" - "regexp" - "sync" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - ) - --// Ensure Runtime satisfies the port at compile time. Attach is added in B5. -+// Ensure Runtime satisfies the port at compile time (Attach in attach.go). - var _ ports.Runtime = (*Runtime)(nil) - - // validSessionID matches agent-orchestrator's assertValidSessionId. - var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - - // hostSession is the in-memory state for a live pty-host connection. - type hostSession struct { - addr string - pid int - } -diff --git a/backend/internal/terminal/pty_unix.go b/backend/internal/adapters/runtime/ptyexec/spawn_unix.go -similarity index 66% -rename from backend/internal/terminal/pty_unix.go -rename to backend/internal/adapters/runtime/ptyexec/spawn_unix.go -index c613bbd..e7d6d2c 100644 ---- a/backend/internal/terminal/pty_unix.go -+++ b/backend/internal/adapters/runtime/ptyexec/spawn_unix.go -@@ -1,37 +1,42 @@ - //go:build !windows - --package terminal -+// Package ptyexec spawns a local PTY around an attach CLI (tmux/zellij) and -+// exposes it as a ports.Stream. It is the shared spawn the terminal layer used -+// to own directly; extracting it lets each runtime adapter back its Attach with -+// the same creack/pty (unix) or go-pty ConPTY (windows) plumbing. -+package ptyexec - - import ( - "context" - "errors" - "os" - "os/exec" - "sync" - "syscall" - "time" - -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/creack/pty" - ) - --// defaultSpawn starts argv on a real PTY via creack/pty, sized rows×cols from --// birth when a size is known: `zellij attach` reads the tty size once at --// startup, and a post-spawn TIOCSWINSZ depends on SIGWINCH delivery that can --// race the client installing its handler — StartWithSize makes the first read --// correct by construction. env, when non-nil, replaces the inherited --// environment (mirrors exec.Cmd.Env semantics). ctx cancellation closes the PTY --// through the same graceful detach path as an explicit client close. Windows uses --// a stub (see pty_windows.go) until a ConPTY path is added. --func defaultSpawn(ctx context.Context, argv, env []string, rows, cols uint16) (ptyProcess, error) { -+// Spawn starts argv on a real PTY via creack/pty, sized rows×cols from birth -+// when a size is known: an attach client reads the tty size once at startup, and -+// a post-spawn TIOCSWINSZ depends on SIGWINCH delivery that can race the client -+// installing its handler — StartWithSize makes the first read correct by -+// construction. env, when non-nil, replaces the inherited environment (mirrors -+// exec.Cmd.Env semantics). ctx cancellation closes the PTY through the same -+// graceful detach path as an explicit client close. Windows uses a ConPTY path -+// (see spawn_windows.go). -+func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) { - if len(argv) == 0 { -- return nil, errors.New("terminal: empty attach command") -+ return nil, errors.New("ptyexec: empty attach command") - } - if err := ctx.Err(); err != nil { - return nil, err - } - cmd := exec.Command(argv[0], argv[1:]...) - if env != nil { - cmd.Env = env - } - var f *os.File - var err error -@@ -58,42 +63,42 @@ type creackPTY struct { - closeErr error - } - - func (p *creackPTY) Read(b []byte) (int, error) { return p.f.Read(b) } - func (p *creackPTY) Write(b []byte) (int, error) { return p.f.Write(b) } - - func (p *creackPTY) Resize(rows, cols uint16) error { - err := pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols}) - // Always follow with an explicit SIGWINCH: the kernel only raises one when - // the size actually changed, so a re-asserted (identical) grid would never -- // reach a zellij client that missed or lost the original signal — the -+ // reach an attach client that missed or lost the original signal — the - // session would stay laid out for a stale size, with no repaint until the - // next real change (the frontend re-sends its grid after each resize burst - // for exactly this self-heal; see useTerminalSession). The client re-reads - // the tty and re-reports to its server; when already in sync it's a no-op. - if p.cmd.Process != nil { - _ = p.cmd.Process.Signal(syscall.SIGWINCH) - } - return err - } - - // detachGrace is how long Close waits for a SIGTERM'd attach process to exit --// on its own before falling back to SIGKILL. A zellij client that is being -+// on its own before falling back to SIGKILL. An attach client that is being - // drained detaches in ~50ms; the grace only runs out for a wedged process. - const detachGrace = 250 * time.Millisecond - - // Close stops the attach process and releases the PTY. - // --// SIGTERM first, SIGKILL as fallback: a SIGTERM'd `zellij attach` deregisters --// itself from the zellij server before exiting, while a SIGKILL'd one leaves -+// SIGTERM first, SIGKILL as fallback: a SIGTERM'd attach client deregisters -+// itself from its mux server before exiting, while a SIGKILL'd one leaves - // deregistration to the server noticing the dead socket. A dead-but-registered --// client pins the session's size (zellij sizes a session to its smallest -+// client pins the session's size (a mux sizes a session to its smallest - // client), so the next attach renders for the ghost's grid — the "terminal - // doesn't repaint to the new size" desync. The master stays open through the - // grace so the run loop's copyOut keeps draining the client's shutdown output - // (a blocked tty write would stall the graceful exit past the grace). - // - // It is idempotent: both the attachment run loop (after copyOut returns) and - // attachment.close (via closeTerminal, conn cleanup, or Manager.Close) call - // Close on the same PTY, and cmd.Wait must run exactly once. A second - // concurrent Wait on the same process blocks forever, deadlocking daemon - // shutdown when a terminal is still attached. -diff --git a/backend/internal/terminal/pty_unix_test.go b/backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go -similarity index 89% -rename from backend/internal/terminal/pty_unix_test.go -rename to backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go -index 9a8bd9a..a0afefa 100644 ---- a/backend/internal/terminal/pty_unix_test.go -+++ b/backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go -@@ -1,53 +1,53 @@ - //go:build !windows - --package terminal -+package ptyexec - - import ( - "context" - "strings" - "testing" - "time" - ) - - // TestCreackPTYCloseIsIdempotent guards the shutdown deadlock: the session run - // loop and session.close both call Close on the same PTY, so cmd.Wait must run - // exactly once. Without the sync.Once a second Wait blocks forever, so this test - // would hang (caught by the watchdog) rather than fail. - func TestCreackPTYCloseIsIdempotent(t *testing.T) { -- p, err := defaultSpawn(context.Background(), []string{"/bin/sh", "-c", "sleep 30"}, nil, 0, 0) -+ p, err := Spawn(context.Background(), []string{"/bin/sh", "-c", "sleep 30"}, nil, 0, 0) - if err != nil { - t.Fatalf("spawn: %v", err) - } - - done := make(chan struct{}) - go func() { - _ = p.Close() - _ = p.Close() // second close must not block on a second cmd.Wait - close(done) - }() - - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("creackPTY.Close did not return: double Close deadlocked on cmd.Wait") - } - } - - // TestCreackPTYResizeSignalsOnIdenticalSize guards the resize self-heal: the - // kernel only raises SIGWINCH when TIOCSWINSZ actually changes the size, so a --// re-asserted (identical) grid relies on Resize's explicit signal. A zellij -+// re-asserted (identical) grid relies on Resize's explicit signal. An attach - // client that lost the original update would otherwise keep its server laid - // out for a stale size forever — the "terminal doesn't repaint after resizing - // the pane" desync. - func TestCreackPTYResizeSignalsOnIdenticalSize(t *testing.T) { -- p, err := defaultSpawn(context.Background(), -+ p, err := Spawn(context.Background(), - []string{"/bin/sh", "-c", `trap 'echo WINCHED' WINCH; while :; do sleep 0.05; done`}, nil, 0, 0) - if err != nil { - t.Fatalf("spawn: %v", err) - } - defer p.Close() - - // Give the shell a beat to install the trap, then resize twice to the SAME - // size. The first call changes the size (fresh PTYs start at 0x0) and the - // second is identical — only the explicit signal can deliver it. - time.Sleep(200 * time.Millisecond) -@@ -73,23 +73,23 @@ func TestCreackPTYResizeSignalsOnIdenticalSize(t *testing.T) { - if err != nil { - break - } - } - t.Fatalf("expected 2 WINCHED traps (one per Resize, including the identical one), got output: %q", out.String()) - } - - // TestCreackPTYSpawnsAtRequestedSize: the child must see the requested grid on - // its very first TIOCGWINSZ, with no SIGWINCH involved — sizing after exec - // races the client installing its WINCH handler (a missed signal strands the --// zellij session at the previous client's size). -+// session at the previous client's size). - func TestCreackPTYSpawnsAtRequestedSize(t *testing.T) { -- p, err := defaultSpawn(context.Background(), []string{"/bin/sh", "-c", "stty size"}, nil, 40, 140) -+ p, err := Spawn(context.Background(), []string{"/bin/sh", "-c", "stty size"}, nil, 40, 140) - if err != nil { - t.Fatalf("spawn: %v", err) - } - defer p.Close() - - deadline := time.Now().Add(5 * time.Second) - var out strings.Builder - buf := make([]byte, 4096) - for time.Now().Before(deadline) { - n, readErr := p.Read(buf) -@@ -100,40 +100,40 @@ func TestCreackPTYSpawnsAtRequestedSize(t *testing.T) { - } - } - if readErr != nil { - break - } - } - t.Fatalf("child did not see the spawn size 40x140, got output: %q", out.String()) - } - - // TestCreackPTYCloseTermsBeforeKill: Close must give the attach process a --// chance to exit on SIGTERM (a zellij client deregisters from its server on -+// chance to exit on SIGTERM (an attach client deregisters from its server on - // SIGTERM; a straight SIGKILL leaves a ghost client that pins the session's - // size), and must still return promptly for a process that ignores SIGTERM. - func TestCreackPTYCloseTermsBeforeKill(t *testing.T) { - t.Run("cooperative process exits within the grace", func(t *testing.T) { -- p, err := defaultSpawn(context.Background(), -+ p, err := Spawn(context.Background(), - []string{"/bin/sh", "-c", `trap 'exit 0' TERM; while :; do sleep 0.05; done`}, nil, 0, 0) - if err != nil { - t.Fatalf("spawn: %v", err) - } - time.Sleep(200 * time.Millisecond) // let the trap install - start := time.Now() - _ = p.Close() - if elapsed := time.Since(start); elapsed >= detachGrace { - t.Fatalf("Close took %v: SIGTERM path did not let a cooperative process exit before the kill grace", elapsed) - } - }) - - t.Run("TERM-ignoring process is killed after the grace", func(t *testing.T) { -- p, err := defaultSpawn(context.Background(), -+ p, err := Spawn(context.Background(), - []string{"/bin/sh", "-c", `trap '' TERM; while :; do sleep 0.05; done`}, nil, 0, 0) - if err != nil { - t.Fatalf("spawn: %v", err) - } - time.Sleep(200 * time.Millisecond) - done := make(chan struct{}) - go func() { - _ = p.Close() - close(done) - }() -diff --git a/backend/internal/terminal/pty_windows.go b/backend/internal/adapters/runtime/ptyexec/spawn_windows.go -similarity index 70% -rename from backend/internal/terminal/pty_windows.go -rename to backend/internal/adapters/runtime/ptyexec/spawn_windows.go -index 5a83400..8f19f55 100644 ---- a/backend/internal/terminal/pty_windows.go -+++ b/backend/internal/adapters/runtime/ptyexec/spawn_windows.go -@@ -1,38 +1,39 @@ - //go:build windows - --package terminal -+package ptyexec - - import ( - "context" - "errors" - "sync" - "time" - -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" - winpty "github.com/aymanbagabas/go-pty" - ) - - // detachGrace mirrors the Unix value: how long Close waits for the attach - // process to exit on its own (closing the ConPTY surfaces as EOF on the - // child's stdin) before falling back to Kill. - const detachGrace = 250 * time.Millisecond - --// defaultSpawn starts argv on a Windows ConPTY and exposes the console pipes --// through the same ptyProcess interface used by the Unix creack/pty path. --// go-pty creates the pseudo-console at 80x25 internally, so we only Resize --// when the caller actually has a grid (mirroring StartWithSize on Unix). --// env, when non-nil, replaces the inherited environment via Win32's native --// CreateProcess env block (mirrors exec.Cmd.Env semantics) — this is how a --// per-session ZELLIJ_SOCKET_DIR reaches the zellij attach client. --func defaultSpawn(ctx context.Context, argv []string, env []string, rows, cols uint16) (ptyProcess, error) { -+// Spawn starts argv on a Windows ConPTY and exposes the console pipes through -+// the same ports.Stream interface used by the Unix creack/pty path. go-pty -+// creates the pseudo-console at 80x25 internally, so we only Resize when the -+// caller actually has a grid (mirroring StartWithSize on Unix). env, when -+// non-nil, replaces the inherited environment via Win32's native CreateProcess -+// env block (mirrors exec.Cmd.Env semantics) — this is how a per-session -+// ZELLIJ_SOCKET_DIR reaches the zellij attach client. -+func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) { - if len(argv) == 0 { -- return nil, errors.New("terminal: empty attach command") -+ return nil, errors.New("ptyexec: empty attach command") - } - pty, err := winpty.New() - if err != nil { - return nil, err - } - if rows > 0 && cols > 0 { - if err := pty.Resize(int(cols), int(rows)); err != nil { - _ = pty.Close() - return nil, err - } -diff --git a/backend/internal/terminal/pty_windows_test.go b/backend/internal/adapters/runtime/ptyexec/spawn_windows_test.go -similarity index 73% -rename from backend/internal/terminal/pty_windows_test.go -rename to backend/internal/adapters/runtime/ptyexec/spawn_windows_test.go -index ee8c7d6..7dab0ad 100644 ---- a/backend/internal/terminal/pty_windows_test.go -+++ b/backend/internal/adapters/runtime/ptyexec/spawn_windows_test.go -@@ -1,68 +1,70 @@ - //go:build windows - --package terminal -+package ptyexec - - import ( - "bytes" - "context" - "errors" - "io" - "strings" - "testing" - "time" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" - ) - --func TestDefaultSpawnWindowsStreamsOutput(t *testing.T) { -- p, err := defaultSpawn(context.Background(), []string{"cmd.exe", "/D", "/Q", "/K"}, nil, 24, 80) -+func TestSpawnWindowsStreamsOutput(t *testing.T) { -+ p, err := Spawn(context.Background(), []string{"cmd.exe", "/D", "/Q", "/K"}, nil, 24, 80) - if err != nil { -- t.Fatalf("defaultSpawn: %v", err) -+ t.Fatalf("Spawn: %v", err) - } - defer p.Close() - if _, err := p.Write([]byte("echo AO_CONPTY_OK\r\n")); err != nil { - t.Fatalf("write PTY: %v", err) - } - - out := readPTYUntil(t, p, "AO_CONPTY_OK", 5*time.Second) - if !strings.Contains(out, "AO_CONPTY_OK") { - t.Fatalf("output %q does not contain marker", out) - } - } - --func TestDefaultSpawnWindowsRejectsEmptyCommand(t *testing.T) { -- _, err := defaultSpawn(context.Background(), nil, nil, 0, 0) -+func TestSpawnWindowsRejectsEmptyCommand(t *testing.T) { -+ _, err := Spawn(context.Background(), nil, nil, 0, 0) - if err == nil || !strings.Contains(err.Error(), "empty attach command") { - t.Fatalf("expected empty attach command error, got %v", err) - } - } - - func TestConPTYCloseIsIdempotent(t *testing.T) { -- p, err := defaultSpawn(context.Background(), []string{"cmd.exe", "/D", "/Q", "/K"}, nil, 24, 80) -+ p, err := Spawn(context.Background(), []string{"cmd.exe", "/D", "/Q", "/K"}, nil, 24, 80) - if err != nil { -- t.Fatalf("defaultSpawn: %v", err) -+ t.Fatalf("Spawn: %v", err) - } - - done := make(chan struct{}) - go func() { - _ = p.Close() - _ = p.Close() - close(done) - }() - - select { - case <-done: - case <-time.After(3 * time.Second): - t.Fatal("Close did not return") - } - } - --func readPTYUntil(t *testing.T, p ptyProcess, marker string, timeout time.Duration) string { -+func readPTYUntil(t *testing.T, p ports.Stream, marker string, timeout time.Duration) string { - t.Helper() - type result struct { - out string - err error - } - results := make(chan result, 1) - go func() { - var buf bytes.Buffer - tmp := make([]byte, 4096) - for { -diff --git a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go -index e6ca4a0..8e486db 100644 ---- a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go -+++ b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go -@@ -8,26 +8,27 @@ import ( - "os" - "runtime" - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - ) - - // Runtime is the union interface that both tmux and zellij satisfy. - // It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods --// the daemon wires directly. -+// the daemon wires directly, including ports.Attacher (Attach) so the terminal -+// layer can open a Stream against the selected runtime. - type Runtime interface { - ports.Runtime // Create, Destroy, IsAlive -+ ports.Attacher - SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error - GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) -- AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) - } - - // Compile-time assertions: both adapters must implement the union interface. - var _ Runtime = (*tmux.Runtime)(nil) - var _ Runtime = (*zellij.Runtime)(nil) - - // New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. - // log is used only for the Windows zellij socket-dir warning; nil is safe. - func New(log *slog.Logger) Runtime { - if runtime.GOOS != "windows" { -diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go -index 90235ca..4cd00e9 100644 ---- a/backend/internal/adapters/runtime/tmux/tmux.go -+++ b/backend/internal/adapters/runtime/tmux/tmux.go -@@ -8,20 +8,21 @@ import ( - "errors" - "fmt" - "os" - "os/exec" - "regexp" - "sort" - "strings" - "time" - "unicode/utf8" - -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/ptyexec" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - ) - - const ( - defaultTimeout = 5 * time.Second - defaultChunkBytes = 16 * 1024 - ) - - var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) -@@ -41,20 +42,21 @@ type Options struct { - // CLI. It implements ports.Runtime. - type Runtime struct { - binary string - shell string - timeout time.Duration - chunkSize int - runner runner - } - - var _ ports.Runtime = (*Runtime)(nil) -+var _ ports.Attacher = (*Runtime)(nil) - - type runner interface { - Run(ctx context.Context, env []string, name string, args ...string) ([]byte, 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 = append(append([]string(nil), os.Environ()...), env...) -@@ -212,24 +214,35 @@ func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lin - if lines <= 0 { - return "", errors.New("tmux runtime: lines must be positive") - } - out, err := r.run(ctx, capturePaneArgs(id, lines)...) - if err != nil { - return "", fmt.Errorf("tmux runtime: capture output %s: %w", id, err) - } - return tailLines(trimTrailingBlankLines(string(out)), lines), nil - } - --// AttachCommand returns the argv a human runs to attach their terminal to the -+// Attach opens a fresh attach Stream by spawning `tmux attach-session` on a -+// local PTY, sized rows x cols from birth when known. ctx cancellation closes -+// the PTY. -+func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { -+ argv, env, err := r.attachCommand(handle) -+ if err != nil { -+ return nil, err -+ } -+ return ptyexec.Spawn(ctx, argv, env, rows, cols) -+} -+ -+// attachCommand returns the argv a human runs to attach their terminal to the - // session. No per-session env block is needed for tmux (unlike zellij's Windows - // ConPTY path), so env is always nil. --func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { -+func (r *Runtime) attachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { - id, err := handleID(handle) - if err != nil { - return nil, nil, err - } - return []string{r.binary, "attach-session", "-t", id}, nil, nil - } - - // run wraps runner.Run with a per-call timeout context. - func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { - cmdCtx, cancel := context.WithTimeout(ctx, r.timeout) -diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go -index 07003d1..a5979b7 100644 ---- a/backend/internal/adapters/runtime/tmux/tmux_test.go -+++ b/backend/internal/adapters/runtime/tmux/tmux_test.go -@@ -520,36 +520,36 @@ func TestGetOutputArgs(t *testing.T) { - } - if got, want := fr.calls[0].args, capturePaneArgs("sess-1", 10); !reflect.DeepEqual(got, want) { - t.Fatalf("capture-pane args = %#v, want %#v", got, want) - } - } - - // -- AttachCommand tests -- - - func TestAttachCommandReturnsExpectedArgv(t *testing.T) { - r := New(Options{Binary: "/usr/bin/tmux", Timeout: time.Second}) -- argv, env, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1"}) -+ argv, env, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1"}) - if err != nil { - t.Fatalf("AttachCommand: %v", err) - } - want := []string{"/usr/bin/tmux", "attach-session", "-t", "sess-1"} - if !reflect.DeepEqual(argv, want) { - t.Fatalf("argv = %#v, want %#v", argv, want) - } - if env != nil { - t.Fatalf("env = %#v, want nil", env) - } - } - - func TestAttachCommandRejectsInvalidHandle(t *testing.T) { - r := New(Options{}) -- _, _, err := r.AttachCommand(ports.RuntimeHandle{ID: ""}) -+ _, _, err := r.attachCommand(ports.RuntimeHandle{ID: ""}) - if err == nil { - t.Fatal("AttachCommand empty handle: got nil, want error") - } - } - - // -- commandError tests -- - - func TestCommandErrorUnwraps(t *testing.T) { - base := errors.New("base") - err := commandError{err: base, output: "details"} -diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go -index 03a1f93..d7a8451 100644 ---- a/backend/internal/adapters/runtime/zellij/zellij.go -+++ b/backend/internal/adapters/runtime/zellij/zellij.go -@@ -11,20 +11,21 @@ import ( - "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" - "strconv" - "strings" - "time" - "unicode/utf8" - -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/ptyexec" - "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" -@@ -62,20 +63,21 @@ type Runtime struct { - timeout time.Duration - shell string - socketDir string - configDir string - chunkSize int - launcher string - runner runner - } - - var _ ports.Runtime = (*Runtime)(nil) -+var _ ports.Attacher = (*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 "" -@@ -364,24 +366,36 @@ func deleteSessionMissingOutput(out string) bool { - 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 -+// Attach opens a fresh attach Stream by spawning the zellij attach client on a -+// local PTY, sized rows x cols from birth when known. On Windows the per-session -+// env block (ZELLIJ_SOCKET_DIR) is applied to the child. ctx cancellation closes -+// the PTY. -+func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { -+ argv, env, err := r.attachCommand(handle) -+ if err != nil { -+ return nil, err -+ } -+ return ptyexec.Spawn(ctx, argv, env, rows, cols) -+} -+ -+// 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) { -+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 - } - -diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go -index 657b575..1a6d6a8 100644 ---- a/backend/internal/adapters/runtime/zellij/zellij_test.go -+++ b/backend/internal/adapters/runtime/zellij/zellij_test.go -@@ -428,21 +428,21 @@ func TestCreateClearsStaleSessionBeforeCreating(t *testing.T) { - if got, want := fr.calls[1].args, []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { - t.Fatalf("delete args = %#v, want %#v", got, want) - } - if got := fr.calls[2].args[:3]; !reflect.DeepEqual(got, []string{"attach", "--create-background", "sess-1"}) { - t.Fatalf("create args prefix = %#v", got) - } - } - - func TestAttachCommandUsesEmbeddedClientOptions(t *testing.T) { - r := New(Options{}) -- args, _, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) -+ args, _, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) - if err != nil { - t.Fatalf("AttachCommand: %v", err) - } - embeddedOptions := []string{ - "--pane-frames", "false", - "--mouse-mode", "true", - "--advanced-mouse-actions", "false", - "--mouse-hover-effects", "false", - "--focus-follows-mouse", "false", - "--mouse-click-through", "false", -@@ -459,21 +459,21 @@ func TestAttachCommandUsesEmbeddedClientOptions(t *testing.T) { - } - want := append(expectedAttachEnvPrefix(), r.binary, "attach", "sess-1", "options") - want = append(want, embeddedOptions...) - if !reflect.DeepEqual(args, want) { - t.Fatalf("AttachCommand = %#v, want %#v", args, want) - } - } - - func TestAttachCommandUsesSocketDir(t *testing.T) { - r := New(Options{SocketDir: "/tmp/zj"}) -- args, _, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) -+ args, _, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) - if err != nil { - t.Fatalf("AttachCommand: %v", err) - } - if runtime.GOOS == "windows" { - if got, want := args[0], r.binary; got != want { - t.Fatalf("attach binary = %q, want %q", got, want) - } - return - } - if got, want := args[:6], []string{"env", "-u", "NO_COLOR", "TERM=xterm-256color", "COLORTERM=truecolor", "ZELLIJ_SOCKET_DIR=/tmp/zj"}; !reflect.DeepEqual(got, want) { -diff --git a/backend/internal/httpd/terminal_mux_test.go b/backend/internal/httpd/terminal_mux_test.go -index e0c9929..7114a06 100644 ---- a/backend/internal/httpd/terminal_mux_test.go -+++ b/backend/internal/httpd/terminal_mux_test.go -@@ -6,38 +6,39 @@ import ( - "net/http/httptest" - "runtime" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/coder/websocket" - "github.com/coder/websocket/wsjson" - -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/ptyexec" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/terminal" - ) - --// stubSource attaches a throwaway shell command instead of a real Zellij pane, so -+// stubSource attaches a throwaway shell command instead of a real mux pane, so - // the /mux path exercises the genuine upgrade + wsjson + Serve + creack/pty flow --// without needing Zellij. The pane reports alive until the first attach happens --// (the mux refuses to attach to a dead pane), then dead, so the command's exit is --// treated as the pane being gone (no re-attach). -+// without needing a runtime. The pane reports alive until the first attach -+// happens (the mux refuses to attach to a dead pane), then dead, so the -+// command's exit is treated as the pane being gone (no re-attach). - type stubSource struct { - argv []string - attached atomic.Bool - } - --func (s *stubSource) AttachCommand(ports.RuntimeHandle) ([]string, []string, error) { -+func (s *stubSource) Attach(ctx context.Context, _ ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { - s.attached.Store(true) -- return s.argv, nil, nil -+ return ptyexec.Spawn(ctx, s.argv, nil, rows, cols) - } - - func (s *stubSource) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { - return !s.attached.Load(), nil - } - - type terminalMuxFrame struct { - Ch string `json:"ch"` - ID string `json:"id"` - Type string `json:"type"` -diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go -index 668da51..97683dc 100644 ---- a/backend/internal/ports/outbound.go -+++ b/backend/internal/ports/outbound.go -@@ -1,16 +1,17 @@ - package ports - - import ( - "context" - "errors" - "fmt" -+ "io" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - ) - - // PRWriter records the PR facts a PR observation carries. The pr table's own DB - // triggers emit the CDC; this just writes the rows. - type PRWriter interface { - // WritePR persists a full PR observation — scalar facts, check runs, and the - // replacement comment set — in one transaction, so the rows and the CDC - // events they emit are all-or-nothing. -@@ -92,20 +93,34 @@ type RuntimeConfig struct { - Argv []string - Env map[string]string - } - - // RuntimeHandle identifies a live runtime instance. Its ID is opaque outside - // the concrete runtime adapter. - type RuntimeHandle struct { - ID string - } - -+// Stream is one live terminal attach: PTY-like bytes plus resize. Returned -+// already-open by a Runtime's Attach. tmux/zellij back it with a local PTY around -+// their attach CLI; conpty backs it with a loopback connection to the pty-host. -+type Stream interface { -+ io.ReadWriteCloser -+ Resize(rows, cols uint16) error -+} -+ -+// Attacher opens a fresh attach Stream for a session handle, sized rows x cols from -+// birth (0 means size not yet known). ctx cancellation must terminate the stream. -+type Attacher interface { -+ Attach(ctx context.Context, handle RuntimeHandle, rows, cols uint16) (Stream, error) -+} -+ - // The Agent port and its supporting types live in agent.go. - - // Workspace is the isolated checkout an agent works in (a git worktree or clone). - type Workspace interface { - Create(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) - Destroy(ctx context.Context, info WorkspaceInfo) error - Restore(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) - } - - // Workspace-level sentinels surfaced through Create/Restore/Destroy so callers -diff --git a/backend/internal/terminal/attachment.go b/backend/internal/terminal/attachment.go -index e45bca6..3526898 100644 ---- a/backend/internal/terminal/attachment.go -+++ b/backend/internal/terminal/attachment.go -@@ -1,114 +1,90 @@ - package terminal - - import ( - "context" - "errors" -- "io" - "log/slog" - "sync" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - ) - --// PTYSource is what a terminal needs from the runtime: the argv that attaches a --// PTY to a session's pane (plus any env that argv needs but is not in the --// daemon's process env — e.g. a per-session ZELLIJ_SOCKET_DIR on Windows), --// and a liveness check used to decide whether a dropped PTY should be --// re-attached or treated as a clean exit. The Zellij runtime adapter --// satisfies this via AttachCommand/IsAlive; the interface lives here, next to --// its only consumer, so terminal does not depend on a concrete adapter. --type PTYSource interface { -- AttachCommand(handle ports.RuntimeHandle) (argv []string, env []string, err error) -+// Source is what the terminal needs from the runtime: open an attach Stream and -+// a liveness check used to decide whether a dropped Stream should be re-attached -+// or treated as a clean exit. The runtime adapters (tmux/zellij/conpty) satisfy -+// it via Attach/IsAlive; the interface lives here, next to its only consumer, so -+// terminal does not depend on a concrete adapter. -+type Source interface { -+ ports.Attacher - IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) - } - --// ptyProcess is a started PTY-backed attach process. It is the injection seam --// that keeps the attach loop testable without a real process: unit tests supply --// a scripted in-memory implementation; production uses a creack/pty-backed one --// (see pty_unix.go). --type ptyProcess interface { -- io.ReadWriteCloser -- Resize(rows, cols uint16) error --} -- --// spawnFunc starts a PTY for argv, sized rows×cols from the start (zero means --// no size was recorded yet — kernel default). Spawning at the client's grid --// matters: the attach process reads the tty size once at startup, and sizing --// the PTY only after exec relies on SIGWINCH delivery that can race the --// process installing its handler — a missed signal leaves the zellij client --// laid out for a stale size. env, when non-nil, is the full env block for the --// child (mirrors exec.Cmd.Env: nil means inherit). ctx cancellation must --// terminate the process. --type spawnFunc func(ctx context.Context, argv []string, env []string, rows, cols uint16) (ptyProcess, error) -- --// reattach policy: a PTY that drops is re-attached while the underlying Zellij -+// reattach policy: a Stream that drops is re-attached while the underlying - // session is still alive, up to maxReattach consecutive failures. An attach that - // survived longer than reattachResetGrace before dropping resets the counter, so - // a long-lived pane that blips recovers but a tight crash-loop gives up. - const ( - defaultMaxReattach = 5 - defaultReattachResetTime = 5 * time.Second - ) - --// attachment is ONE client's hold on a pane: a private `zellij attach` PTY --// spawned per mux open, streaming to a single sink. Zellij is the multiplexer — --// it owns the session's screen state and scrollback, and answers every fresh --// attach with its init handshake (alt screen, bracketed paste, and other terminal --// modes enabled by the embedded client options) followed by a faithful repaint. --// That handshake is why the PTY is per-client and there is no server-side replay --// buffer: a byte ring can replay recent output, but the one-time mode negotiation --// at the head of the stream scrolls out of any bounded buffer. A fresh attach per --// client makes Zellij re-send it, every time, by construction. -+// attachment is ONE client's hold on a pane: a private attach Stream opened per -+// mux open, streaming to a single sink. The runtime is the multiplexer — it owns -+// the session's screen state and scrollback, and answers every fresh attach with -+// its init handshake (alt screen, bracketed paste, scrollback replay) followed by -+// a faithful repaint. That handshake is why the Stream is per-client and there is -+// no terminal-layer replay buffer: a byte ring can replay recent output, but the -+// one-time mode negotiation at the head of the stream scrolls out of any bounded -+// buffer. A fresh attach per client makes the runtime re-send it, every time, by -+// construction. - // --// onOpen fires once the attach PTY is actually ready to accept input. onData -+// onOpen fires once the attach Stream is actually ready to accept input. onData - // must not block: the WS layer funnels frames onto its own buffered writer. - // onExit fires at most once, when the attach loop gives up (runtime dead, - // attach failure cap) — never on close(). - type attachment struct { - id string - handle ports.RuntimeHandle -- src PTYSource -- spawn spawnFunc -+ src Source - log *slog.Logger - onOpen func() - onData func(data []byte) - onExit func() - - maxReattach int - resetGrace time.Duration - - mu sync.Mutex -- pty ptyProcess -+ pty ports.Stream - cancel context.CancelFunc - rows uint16 // last size the client asked for; re-applied on every attach - cols uint16 - closed bool - exited bool - opened bool - inputReady bool - pendingInput [][]byte - } - --func newAttachment(id string, handle ports.RuntimeHandle, src PTYSource, spawn spawnFunc, onOpen func(), onData func([]byte), onExit func(), log *slog.Logger) *attachment { -+func newAttachment(id string, handle ports.RuntimeHandle, src Source, onOpen func(), onData func([]byte), onExit func(), log *slog.Logger) *attachment { - if log == nil { - log = slog.Default() - } - if onData == nil { - onData = func([]byte) {} - } - return &attachment{ - id: id, - handle: handle, - src: src, -- spawn: spawn, - log: log, - onOpen: onOpen, - onData: onData, - onExit: onExit, - maxReattach: defaultMaxReattach, - resetGrace: defaultReattachResetTime, - } - } - - // run drives attach → read-loop → re-attach until the pane exits cleanly, the -@@ -121,21 +97,21 @@ func (a *attachment) run(ctx context.Context) { - } - defer a.clearRunCancel(cancel) - - failures := 0 - for { - if a.shouldStop(ctx) { - return - } - - // Gate EVERY attach (including the first) on the runtime actually -- // being alive. `zellij attach` resurrects EXITED sessions — re-running -+ // being alive. A mux attach resurrects EXITED sessions — re-running - // the serialized agent command — so attaching to a dead handle would - // re-create a runtime the daemon already destroyed, outside lifecycle - // control. A definitive "not alive" is a clean exit. A probe ERROR is - // not proof of death: it retries with backoff up to the same - // consecutive-failure cap as attach failures. - alive, err := a.src.IsAlive(ctx, a.handle) - if a.shouldStop(ctx) { - return - } - if err != nil { -@@ -147,43 +123,35 @@ func (a *attachment) run(ctx context.Context) { - if !a.backoff(ctx, failures) { - return - } - continue - } - if !alive { - a.markExited() - return - } - -- argv, env, err := a.src.AttachCommand(a.handle) -- if a.shouldStop(ctx) { -- return -- } -- if err != nil { -- a.fail("attach command: " + err.Error()) -- return -- } - rows, cols := a.size() - if a.shouldStop(ctx) { - return - } -- p, err := a.spawn(ctx, argv, env, rows, cols) -+ p, err := a.src.Attach(ctx, a.handle, rows, cols) - if a.shouldStop(ctx) { - if p != nil { - _ = p.Close() - } - return - } - if err != nil { - failures++ - if failures > a.maxReattach { -- a.fail("spawn pty: " + err.Error()) -+ a.fail("attach: " + err.Error()) - return - } - if !a.backoff(ctx, failures) { - return - } - continue - } - - if !a.setPTY(p) { - _ = p.Close() -@@ -207,21 +175,21 @@ func (a *attachment) run(ctx context.Context) { - return - } - if !a.backoff(ctx, failures) { - return - } - a.log.Debug("terminal re-attaching", "id", a.id, "failures", failures) - } - } - - // copyOut pumps PTY output to the sink until the PTY closes or errors. --func (a *attachment) copyOut(p ptyProcess) { -+func (a *attachment) copyOut(p ports.Stream) { - buf := make([]byte, 32*1024) - for { - n, err := p.Read(buf) - if n > 0 { - chunk := make([]byte, n) - copy(chunk, buf[:n]) - a.onData(chunk) - } - if err != nil { - return -@@ -284,33 +252,33 @@ func (a *attachment) resize(rows, cols uint16) error { - a.rows, a.cols = rows, cols - pty := a.pty - a.mu.Unlock() - if pty == nil { - return nil - } - return pty.Resize(rows, cols) - } - - // size returns the client's last requested grid (zero before the first --// open/resize recorded one). The spawn path reads it so the PTY starts at the --// client's grid instead of the kernel default. -+// open/resize recorded one). The attach path reads it so the Stream starts at -+// the client's grid instead of the kernel default. - func (a *attachment) size() (rows, cols uint16) { - a.mu.Lock() - defer a.mu.Unlock() - return a.rows, a.cols - } - --// setPTY publishes a freshly attached PTY and replays the client's last --// requested size onto it (see resize) — the spawn already started at the size -+// setPTY publishes a freshly attached Stream and replays the client's last -+// requested size onto it (see resize) — the attach already started at the size - // read in run, but a resize frame can land between that read and registration --// here; the replay (Setsize + explicit WINCH) converges the late case. --func (a *attachment) setPTY(p ptyProcess) bool { -+// here; the replay (Resize) converges the late case. -+func (a *attachment) setPTY(p ports.Stream) bool { - a.mu.Lock() - if a.closed || a.exited { - a.mu.Unlock() - return false - } - a.pty = p - a.inputReady = false - rows, cols := a.rows, a.cols - shouldOpen := !a.opened - if shouldOpen { -@@ -338,31 +306,31 @@ func (a *attachment) setPTY(p ptyProcess) bool { - - for _, chunk := range pending { - if _, err := p.Write(chunk); err != nil { - a.fail("flush pending input: " + err.Error()) - return false - } - } - } - } - --func (a *attachment) clearPTY(p ptyProcess) { -+func (a *attachment) clearPTY(p ports.Stream) { - a.mu.Lock() - if a.pty == p { - a.pty = nil - a.inputReady = false - } - a.mu.Unlock() - } - - // close detaches this client: stop re-attaching and kill the attach PTY. It --// never touches the Zellij session itself, which the zellij server keeps alive -+// never touches the runtime session itself, which the mux server keeps alive - // for other clients. - func (a *attachment) close() { - a.mu.Lock() - if a.closed { - a.mu.Unlock() - return - } - a.closed = true - pty := a.pty - a.pty = nil -diff --git a/backend/internal/terminal/attachment_integration_test.go b/backend/internal/terminal/attachment_integration_test.go -index c2eb899..452362f 100644 ---- a/backend/internal/terminal/attachment_integration_test.go -+++ b/backend/internal/terminal/attachment_integration_test.go -@@ -36,21 +36,21 @@ func TestAttachmentStreamsRealZellijPane(t *testing.T) { - SessionID: domain.SessionID(name), - WorkspacePath: t.TempDir(), - Argv: []string{"sh", "-lc", "printf AO_READY\\n; exec sh -i"}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) }) - - var got safeBytes -- a := newAttachment(name, handle, rt, defaultSpawn, nil, got.add, nil, testLogger()) -+ a := newAttachment(name, handle, rt, nil, got.add, nil, testLogger()) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go a.run(ctx) - - // Type a unique marker and expect it echoed back through the PTY. - eventually(t, 3*time.Second, func() bool { return a.write([]byte("echo AO_MARKER_42\n")) == nil }) - eventually(t, 5*time.Second, func() bool { return strings.Contains(got.string(), "AO_MARKER_42") }) - - // A fresh attach must carry zellij's alt-screen init handshake. Mouse - // reporting is deliberately disabled for AO's embedded client, so this test -@@ -91,21 +91,21 @@ func TestAttachmentReattachAdoptsNewSize(t *testing.T) { - Argv: []string{"sh", "-lc", "printf AO_READY\\n; exec sh -i"}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) }) - - attachAt := func(rows, cols uint16) (*attachment, *safeBytes, <-chan struct{}, context.CancelFunc) { - var got safeBytes - opened := make(chan struct{}) -- a := newAttachment(name, handle, rt, defaultSpawn, func() { close(opened) }, got.add, nil, testLogger()) -+ a := newAttachment(name, handle, rt, func() { close(opened) }, got.add, nil, testLogger()) - if err := a.resize(rows, cols); err != nil { - t.Fatalf("record size: %v", err) - } - ctx, cancel := context.WithCancel(context.Background()) - go a.run(ctx) - return a, &got, opened, cancel - } - - // Client A at 115x37: wait for the pane shell, then detach. - a, _, openedA, cancelA := attachAt(37, 115) -diff --git a/backend/internal/terminal/attachment_test.go b/backend/internal/terminal/attachment_test.go -index a085194..634edfc 100644 ---- a/backend/internal/terminal/attachment_test.go -+++ b/backend/internal/terminal/attachment_test.go -@@ -6,108 +6,107 @@ import ( - "log/slog" - "sync" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - ) - - func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } - --func newTestAttachment(src PTYSource, spawn spawnFunc, onData func([]byte), onExit func()) *attachment { -- return newTestAttachmentWithOpen(src, spawn, nil, onData, onExit) -+func newTestAttachment(src Source, onData func([]byte), onExit func()) *attachment { -+ return newTestAttachmentWithOpen(src, nil, onData, onExit) - } - --func newTestAttachmentWithOpen(src PTYSource, spawn spawnFunc, onOpen func(), onData func([]byte), onExit func()) *attachment { -- return newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, src, spawn, onOpen, onData, onExit, testLogger()) -+func newTestAttachmentWithOpen(src Source, onOpen func(), onData func([]byte), onExit func()) *attachment { -+ return newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, src, onOpen, onData, onExit, testLogger()) - } - --func currentPTY(a *attachment) ptyProcess { -+func currentPTY(a *attachment) ports.Stream { - a.mu.Lock() - defer a.mu.Unlock() - return a.pty - } - - func TestAttachmentStreamsOutputToSink(t *testing.T) { -- src := &fakeSource{alive: true} - pty := newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{pty}} -+ src := &fakeSource{alive: true, spawner: sp} - - var sink safeBytes -- a := newTestAttachment(src, sp.spawn, sink.add, nil) -+ a := newTestAttachment(src, sink.add, nil) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go a.run(ctx) - - pty.push([]byte("hello")) - eventually(t, time.Second, func() bool { return sink.string() == "hello" }) - } - - func TestAttachmentWriteAndResizeReachPTY(t *testing.T) { -- src := &fakeSource{alive: true} - pty := newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{pty}} -- a := newTestAttachment(src, sp.spawn, nil, nil) -+ src := &fakeSource{alive: true, spawner: sp} -+ a := newTestAttachment(src, nil, nil) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go a.run(ctx) - - eventually(t, time.Second, func() bool { return a.write([]byte("ls\n")) == nil }) - eventually(t, time.Second, func() bool { return string(pty.writtenBytes()) == "ls\n" }) - - if err := a.resize(24, 80); err != nil { - t.Fatalf("resize: %v", err) - } - eventually(t, time.Second, func() bool { - rs := pty.resizeCalls() - return len(rs) == 1 && rs[0] == [2]uint16{24, 80} - }) - } - - func TestAttachmentSignalsOpenOnlyAfterPTYIsPublished(t *testing.T) { -- src := &fakeSource{alive: true} - pty := newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{pty}} -+ src := &fakeSource{alive: true, spawner: sp} - - opened := make(chan bool, 1) - var a *attachment -- a = newTestAttachmentWithOpen(src, sp.spawn, func() { -+ a = newTestAttachmentWithOpen(src, func() { - opened <- currentPTY(a) == pty - }, nil, nil) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go a.run(ctx) - - select { - case sawPTY := <-opened: - if !sawPTY { - t.Fatal("open callback fired before the PTY was published") - } - case <-time.After(time.Second): - t.Fatal("open callback did not fire") - } - } - - func TestAttachmentBuffersInputUntilPTYReady(t *testing.T) { -- src := &fakeSource{alive: true} - pty := newFakePTY() - spawnStarted := make(chan struct{}) - releaseSpawn := make(chan struct{}) -- spawn := func(context.Context, []string, []string, uint16, uint16) (ptyProcess, error) { -+ src := &fakeSource{alive: true, attachFn: func(context.Context, uint16, uint16) (ports.Stream, error) { - close(spawnStarted) - <-releaseSpawn - return pty, nil -- } -- a := newTestAttachment(src, spawn, nil, nil) -+ }} -+ a := newTestAttachment(src, nil, nil) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go a.run(ctx) - - select { - case <-spawnStarted: - case <-time.After(time.Second): - t.Fatal("spawn was not reached") - } -@@ -116,51 +115,51 @@ func TestAttachmentBuffersInputUntilPTYReady(t *testing.T) { - } - close(releaseSpawn) - - eventually(t, time.Second, func() bool { return string(pty.writtenBytes()) == "hello\n" }) - } - - // A size requested before the PTY exists (the open frame's cols/rows, or a - // resize racing the attach) must not be lost: the attach applies it the moment - // the PTY is up, instead of leaving the pane at the kernel default grid. - func TestAttachmentAppliesRequestedSizeOnAttach(t *testing.T) { -- src := &fakeSource{alive: true} - pty := newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{pty}} -- a := newTestAttachment(src, sp.spawn, nil, nil) -+ src := &fakeSource{alive: true, spawner: sp} -+ a := newTestAttachment(src, nil, nil) - - if err := a.resize(30, 100); err != nil { - t.Fatalf("resize before attach: %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go a.run(ctx) - - eventually(t, time.Second, func() bool { - rs := pty.resizeCalls() - return len(rs) == 1 && rs[0] == [2]uint16{30, 100} - }) - } - --// The PTY must be SPAWNED at the recorded grid, not sized after exec: the --// attach process (zellij) reads the tty size once at startup, and a post-spawn --// TIOCSWINSZ depends on SIGWINCH delivery that can race the client installing --// its handler — a missed signal left the session laid out for the previous --// client's size (the "terminal doesn't repaint after a resize" desync). Also --// covers re-attach: a later resize must reach the NEXT spawn, not the first --// grid forever. -+// The Stream must be OPENED at the recorded grid, not sized after attach: the -+// attach client reads the tty size once at startup, and a post-attach resize -+// depends on SIGWINCH delivery that can race the client installing its handler -+// — a missed signal left the session laid out for the previous client's size -+// (the "terminal doesn't repaint after a resize" desync). Also covers -+// re-attach: a later resize must reach the NEXT attach, not the first grid -+// forever. - func TestAttachmentSpawnsPTYAtRecordedSize(t *testing.T) { -- src := &fakeSource{alive: true} - first := newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{first}} -- a := newTestAttachment(src, sp.spawn, nil, nil) -+ src := &fakeSource{alive: true, spawner: sp} -+ a := newTestAttachment(src, nil, nil) - a.resetGrace = time.Hour // keep the failure counter deterministic - - if err := a.resize(37, 115); err != nil { - t.Fatalf("resize before attach: %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go a.run(ctx) - -@@ -176,103 +175,103 @@ func TestAttachmentSpawnsPTYAtRecordedSize(t *testing.T) { - } - _ = first.Close() - - eventually(t, time.Second, func() bool { - ss := sp.spawnSizes() - return len(ss) == 2 && ss[1] == [2]uint16{40, 148} - }) - } - - func TestAttachmentSkipsReattachOnCleanExit(t *testing.T) { -- src := &fakeSource{alive: true} // alive for the first attach - pty := newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{pty}} -+ src := &fakeSource{alive: true, spawner: sp} // alive for the first attach - - exited := make(chan struct{}) -- a := newTestAttachment(src, sp.spawn, nil, func() { close(exited) }) -+ a := newTestAttachment(src, nil, func() { close(exited) }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go a.run(ctx) - - eventually(t, time.Second, func() bool { return sp.calls() == 1 }) -- src.setAlive(false) // Zellij session gone -> no re-attach -+ src.setAlive(false) // runtime session gone -> no re-attach - pty.Close() // pane ends - select { - case <-exited: - case <-time.After(time.Second): - t.Fatal("expected exit callback after clean pane exit") - } - if got := sp.calls(); got != 1 { - t.Fatalf("expected exactly one attach, got %d", got) - } - } - --// TestAttachmentNeverAttachesToDeadRuntime covers the resurrection bug: `zellij --// attach` on a killed-but-cached session resurrects it, re-running the agent -+// TestAttachmentNeverAttachesToDeadRuntime covers the resurrection bug: a mux -+// attach on a killed-but-cached session resurrects it, re-running the agent - // command. An attachment whose runtime probes definitively dead must therefore --// report exited WITHOUT ever spawning an attach PTY — even on the very first -+// report exited WITHOUT ever opening an attach Stream — even on the very first - // open (the original code only checked liveness on re-attach). - func TestAttachmentNeverAttachesToDeadRuntime(t *testing.T) { -- src := &fakeSource{alive: false} - sp := &fakeSpawner{} -+ src := &fakeSource{alive: false, spawner: sp} - - exited := make(chan struct{}) -- a := newTestAttachment(src, sp.spawn, nil, func() { close(exited) }) -+ a := newTestAttachment(src, nil, func() { close(exited) }) - - go a.run(context.Background()) - select { - case <-exited: - case <-time.After(time.Second): - t.Fatal("expected exit when runtime is dead before first attach") - } - if got := sp.calls(); got != 0 { -- t.Fatalf("attach must never run against a dead runtime, got %d spawns", got) -+ t.Fatalf("attach must never run against a dead runtime, got %d attaches", got) - } - } - - // TestAttachmentRetriesProbeErrorsBeforeAttaching pins the hard rule that a - // failed liveness probe is NOT proof of death: a transient probe error must - // not flip the terminal to exited, and the attach proceeds once the probe - // recovers. - func TestAttachmentRetriesProbeErrorsBeforeAttaching(t *testing.T) { -- src := &fakeSource{aliveErr: io.ErrUnexpectedEOF} - pty := newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{pty}} -- a := newTestAttachment(src, sp.spawn, nil, nil) -+ src := &fakeSource{aliveErr: io.ErrUnexpectedEOF, spawner: sp} -+ a := newTestAttachment(src, nil, nil) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go a.run(ctx) - - // While probes error the attachment must neither exit nor attach. - time.Sleep(50 * time.Millisecond) - if a.isExited() { - t.Fatal("probe error must not be treated as runtime death") - } - if got := sp.calls(); got != 0 { -- t.Fatalf("attach must wait for a successful probe, got %d spawns", got) -+ t.Fatalf("attach must wait for a successful probe, got %d attaches", got) - } - - // Probe recovers -> the attach goes through. - src.setAliveResult(true, nil) - eventually(t, 2*time.Second, func() bool { return sp.calls() == 1 }) - if a.isExited() { - t.Fatal("attachment exited despite a live runtime") - } - } - - func TestAttachmentReattachesWhileSessionAlive(t *testing.T) { -- src := &fakeSource{alive: true} // session still alive -> re-attach on drop - p1, p2 := newFakePTY(), newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{p1, p2}} -- a := newTestAttachment(src, sp.spawn, nil, nil) -+ src := &fakeSource{alive: true, spawner: sp} // session still alive -> re-attach on drop -+ a := newTestAttachment(src, nil, nil) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go a.run(ctx) - - eventually(t, time.Second, func() bool { return sp.calls() >= 1 }) - if err := a.resize(24, 80); err != nil { - t.Fatalf("resize: %v", err) - } - p1.Close() // first attach drops -@@ -288,48 +287,50 @@ func TestAttachmentReattachesWhileSessionAlive(t *testing.T) { - } - return false - }) - - // Now the session is gone: the next drop must not re-attach. - src.setAlive(false) - p2.Close() - eventually(t, 2*time.Second, func() bool { return a.isExited() }) - } - --func TestAttachmentFailsWhenAttachCommandErrors(t *testing.T) { -+// A persistent Attach error is treated like the old spawn-error path: it backs -+// off and retries up to the failure cap, then reports exited. (The old -+// AttachCommand-error path failed immediately; folding the argv build into -+// Attach means an attach failure now shares the spawn-failure retry policy, -+// which is the correct behavior for a transient dial/exec failure.) -+func TestAttachmentFailsWhenAttachErrors(t *testing.T) { - src := &fakeSource{alive: true, attachErr: io.ErrUnexpectedEOF} -- sp := &fakeSpawner{} - - exited := make(chan struct{}) -- a := newTestAttachment(src, sp.spawn, nil, func() { close(exited) }) -+ a := newTestAttachment(src, nil, func() { close(exited) }) -+ a.maxReattach = 2 // keep the retry budget small so the test is fast - - go a.run(context.Background()) - select { - case <-exited: -- case <-time.After(time.Second): -- t.Fatal("expected exit when attach command fails") -- } -- if sp.calls() != 0 { -- t.Fatalf("spawn should not run when attach command errors, got %d calls", sp.calls()) -+ case <-time.After(3 * time.Second): -+ t.Fatal("expected exit after attach errors exhaust the retry budget") - } - } - - // close() is a detach, not a pane death: it must stop the attach loop and kill --// the client's PTY without firing onExit — the Zellij session is still alive -+// the client's PTY without firing onExit — the runtime session is still alive - // and an exited frame would wrongly flip the client UI to its terminal state. - func TestAttachmentCloseDoesNotFireExit(t *testing.T) { -- src := &fakeSource{alive: true} - pty := newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{pty}} -+ src := &fakeSource{alive: true, spawner: sp} - - exited := make(chan struct{}) -- a := newTestAttachment(src, sp.spawn, nil, func() { close(exited) }) -+ a := newTestAttachment(src, nil, func() { close(exited) }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - done := make(chan struct{}) - go func() { - a.run(ctx) - close(done) - }() - - eventually(t, time.Second, func() bool { return sp.calls() == 1 }) -@@ -339,21 +340,21 @@ func TestAttachmentCloseDoesNotFireExit(t *testing.T) { - case <-done: - case <-time.After(time.Second): - t.Fatal("run must return after close") - } - select { - case <-exited: - t.Fatal("close must not fire onExit") - default: - } - if got := sp.calls(); got != 1 { -- t.Fatalf("close must stop re-attaching, got %d spawns", got) -+ t.Fatalf("close must stop re-attaching, got %d attaches", got) - } - } - - type closeOrderPTY struct { - *fakePTY - ctx context.Context - before chan struct{} - after chan struct{} - once sync.Once - } -@@ -364,34 +365,33 @@ func (p *closeOrderPTY) Close() error { - case <-p.ctx.Done(): - close(p.after) - default: - close(p.before) - } - }) - return p.fakePTY.Close() - } - - func TestAttachmentCloseClosesPTYBeforeCancel(t *testing.T) { -- src := &fakeSource{alive: true} - beforeCancel := make(chan struct{}) - afterCancel := make(chan struct{}) - var spawnCtx context.Context -- spawn := func(ctx context.Context, _ []string, _ []string, _, _ uint16) (ptyProcess, error) { -+ src := &fakeSource{alive: true, attachFn: func(ctx context.Context, _, _ uint16) (ports.Stream, error) { - spawnCtx = ctx - return &closeOrderPTY{ - fakePTY: newFakePTY(), - ctx: ctx, - before: beforeCancel, - after: afterCancel, - }, nil -- } -- a := newTestAttachment(src, spawn, nil, nil) -+ }} -+ a := newTestAttachment(src, nil, nil) - - done := make(chan struct{}) - go func() { - a.run(context.Background()) - close(done) - }() - eventually(t, time.Second, func() bool { return currentPTY(a) != nil }) - - a.close() - select { -diff --git a/backend/internal/terminal/fakes_test.go b/backend/internal/terminal/fakes_test.go -index c0818f3..b196aec 100644 ---- a/backend/internal/terminal/fakes_test.go -+++ b/backend/internal/terminal/fakes_test.go -@@ -3,37 +3,43 @@ package terminal - import ( - "context" - "io" - "sync" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - ) - --// fakeSource is a scripted PTYSource. -+// fakeSource is a scripted terminal Source: Attach hands out fake Streams from -+// an embedded spawner (or a custom attachFn closure); IsAlive is scriptable. -+// attachErr makes Attach fail. - type fakeSource struct { -- argv []string -+ spawner *fakeSpawner -+ attachFn func(ctx context.Context, rows, cols uint16) (ports.Stream, error) - mu sync.Mutex - alive bool - aliveErr error - attachErr error - } - --func (f *fakeSource) AttachCommand(ports.RuntimeHandle) ([]string, []string, error) { -+func (f *fakeSource) Attach(ctx context.Context, _ ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { - if f.attachErr != nil { -- return nil, nil, f.attachErr -+ return nil, f.attachErr - } -- if f.argv == nil { -- return []string{"zellij", "attach"}, nil, nil -+ if f.attachFn != nil { -+ return f.attachFn(ctx, rows, cols) - } -- return f.argv, nil, nil -+ if f.spawner == nil { -+ f.spawner = &fakeSpawner{} -+ } -+ return f.spawner.spawn(rows, cols) - } - - func (f *fakeSource) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { - f.mu.Lock() - defer f.mu.Unlock() - return f.alive, f.aliveErr - } - - func (f *fakeSource) setAlive(v bool) { - f.mu.Lock() -@@ -41,22 +47,22 @@ func (f *fakeSource) setAlive(v bool) { - f.mu.Unlock() - } - - func (f *fakeSource) setAliveResult(v bool, err error) { - f.mu.Lock() - f.alive = v - f.aliveErr = err - f.mu.Unlock() - } - --// fakePTY is a scripted ptyProcess: Read drains the out channel, Write records, --// Resize records, and Close unblocks reads. -+// fakePTY is a scripted ports.Stream: Read drains the out channel, Write -+// records, Resize records, and Close unblocks reads. - type fakePTY struct { - out chan []byte - closed chan struct{} - once sync.Once - - mu sync.Mutex - written []byte - resizes [][2]uint16 - } - -@@ -103,30 +109,31 @@ func (p *fakePTY) writtenBytes() []byte { - } - - func (p *fakePTY) resizeCalls() [][2]uint16 { - p.mu.Lock() - defer p.mu.Unlock() - return append([][2]uint16(nil), p.resizes...) - } - - // fakeSpawner hands out pre-built fakePTYs in order; once exhausted it returns - // idle PTYs that block until closed (so a re-attach loop does not busy-spin). -+// It is the attach seam the fakeSource backs: each Attach call is one spawn. - type fakeSpawner struct { - mu sync.Mutex - ptys []*fakePTY - n int - err error - created []*fakePTY -- sizes [][2]uint16 // rows×cols passed to each spawn call, in order -+ sizes [][2]uint16 // rows×cols passed to each attach call, in order - } - --func (f *fakeSpawner) spawn(_ context.Context, _ []string, _ []string, rows, cols uint16) (ptyProcess, error) { -+func (f *fakeSpawner) spawn(rows, cols uint16) (ports.Stream, error) { - f.mu.Lock() - defer f.mu.Unlock() - if f.err != nil { - return nil, f.err - } - f.sizes = append(f.sizes, [2]uint16{rows, cols}) - var p *fakePTY - if f.n < len(f.ptys) { - p = f.ptys[f.n] - } else { -diff --git a/backend/internal/terminal/logger_test.go b/backend/internal/terminal/logger_test.go -index ba08a2d..1586e45 100644 ---- a/backend/internal/terminal/logger_test.go -+++ b/backend/internal/terminal/logger_test.go -@@ -1,19 +1,19 @@ - package terminal - - import ( - "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - ) - - func TestNilLoggerFallsBackToDefault(t *testing.T) { -- mgr := NewManager(&fakeSource{}, nil, nil, WithSpawn((&fakeSpawner{}).spawn)) -+ mgr := NewManager(&fakeSource{}, nil, nil) - defer mgr.Close() - if mgr.log == nil { - t.Fatal("manager logger is nil") - } -- a := newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, &fakeSource{}, (&fakeSpawner{}).spawn, nil, nil, nil, nil) -+ a := newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, &fakeSource{}, nil, nil, nil, nil) - if a.log == nil { - t.Fatal("attachment logger is nil") - } - } -diff --git a/backend/internal/terminal/manager.go b/backend/internal/terminal/manager.go -index 88ff36e..6dccfae 100644 ---- a/backend/internal/terminal/manager.go -+++ b/backend/internal/terminal/manager.go -@@ -27,61 +27,56 @@ type wsConn interface { - WriteJSON(ctx context.Context, v any) error - Ping(ctx context.Context) error - Close(reason string) error - } - - const ( - defaultHeartbeat = 15 * time.Second - defaultWriteBuffer = 1024 - ) - --// Manager serves WebSocket clients, spawning one attach PTY per opened pane -+// Manager serves WebSocket clients, opening one attach Stream per opened pane - // per connection. There is no shared per-pane state to outlive a connection: --// the Zellij server owns the session (screen, scrollback, modes), and every --// fresh attach gets its full handshake + repaint. A client reconnect simply --// attaches again. -+// the runtime owns the session (screen, scrollback, modes), and every fresh -+// attach gets its full handshake + repaint. A client reconnect simply attaches -+// again. - type Manager struct { -- src PTYSource -+ src Source - events EventSource -- spawn spawnFunc - log *slog.Logger - heartbeat time.Duration - - // ctx scopes every attachment's PTY lifetime; cancelled by Close. - ctx context.Context - cancel context.CancelFunc - - mu sync.Mutex - attachments map[*attachment]struct{} - closed bool - } - - // Option configures a Manager. - type Option func(*Manager) - --// WithSpawn overrides the PTY spawner (tests inject a fake). --func WithSpawn(fn spawnFunc) Option { return func(m *Manager) { m.spawn = fn } } -- - // WithHeartbeat overrides the ping interval. - func WithHeartbeat(d time.Duration) Option { return func(m *Manager) { m.heartbeat = d } } - --// NewManager builds a Manager. src attaches PTYs; events feeds the session -+// NewManager builds a Manager. src opens attach Streams; events feeds the session - // channel (may be nil to disable it). A nil logger falls back to slog.Default. --func NewManager(src PTYSource, events EventSource, log *slog.Logger, opts ...Option) *Manager { -+func NewManager(src Source, events EventSource, log *slog.Logger, opts ...Option) *Manager { - if log == nil { - log = slog.Default() - } - ctx, cancel := context.WithCancel(context.Background()) - m := &Manager{ - src: src, - events: events, -- spawn: defaultSpawn, - log: log, - heartbeat: defaultHeartbeat, - ctx: ctx, - cancel: cancel, - attachments: map[*attachment]struct{}{}, - } - for _, opt := range opts { - opt(m) - } - return m -@@ -198,39 +193,39 @@ func (c *connState) handleTerminal(msg clientMsg) { - } - case msgResize: - if a := c.lookup(msg.ID); a != nil { - _ = a.resize(msg.Rows, msg.Cols) - } - case msgClose: - c.closeTerminal(msg.ID) - } - } - --// openTerminal spawns this connection's own attach PTY for the pane. rows/cols --// are the client's grid from the open frame, applied as the PTY's initial size -+// openTerminal opens this connection's own attach Stream for the pane. rows/cols -+// are the client's grid from the open frame, applied as the Stream's initial size - // (a resize that raced ahead of the attach would otherwise be lost). - func (c *connState) openTerminal(id string, rows, cols uint16) { - if id == "" { - c.enqueue(serverMsg{Ch: chTerminal, Type: msgError, Error: "missing terminal id"}) - return - } - c.mu.Lock() - if _, ok := c.terms[id]; ok { - c.mu.Unlock() - return // already open on this conn; avoid a duplicate attach - } - c.mu.Unlock() - - // a is captured by onExit before assignment; safe because the attach loop — - // the only thing that fires onExit — starts after the registration below. - var a *attachment -- a = newAttachment(id, ports.RuntimeHandle{ID: id}, c.mgr.src, c.mgr.spawn, -+ a = newAttachment(id, ports.RuntimeHandle{ID: id}, c.mgr.src, - func() { - c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgOpened}) - }, - func(data []byte) { - c.enqueue(serverMsg{ - Ch: chTerminal, - ID: id, - Type: msgData, - Data: base64.StdEncoding.EncodeToString(data), - }) -diff --git a/backend/internal/terminal/manager_test.go b/backend/internal/terminal/manager_test.go -index 8c3e3ee..3bb2134 100644 ---- a/backend/internal/terminal/manager_test.go -+++ b/backend/internal/terminal/manager_test.go -@@ -2,20 +2,21 @@ package terminal - - import ( - "context" - "encoding/base64" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/cdc" -+ "github.com/aoagents/agent-orchestrator/backend/internal/ports" - ) - - // fakeConn is an in-memory wsConn driven by channels. - type fakeConn struct { - in chan clientMsg - out chan serverMsg - pings int32 - once sync.Once - closed chan struct{} - } -@@ -61,24 +62,24 @@ func recv(t *testing.T, c *fakeConn, ch, typ string, d time.Duration) serverMsg - if m.Ch == ch && m.Type == typ { - return m - } - case <-deadline: - t.Fatalf("did not receive %s/%s within %s", ch, typ, d) - } - } - } - - func TestServeOpenStreamsAndWritesTerminal(t *testing.T) { -- src := &fakeSource{alive: true} - pty := newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{pty}} -- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) -+ src := &fakeSource{alive: true, spawner: sp} -+ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) - defer mgr.Close() - - conn := newFakeConn() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go mgr.Serve(ctx, conn) - - conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} - recv(t, conn, chTerminal, msgOpened, time.Second) - -@@ -93,30 +94,29 @@ func TestServeOpenStreamsAndWritesTerminal(t *testing.T) { - eventually(t, time.Second, func() bool { return string(pty.writtenBytes()) == "whoami\n" }) - - conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgResize, Rows: 30, Cols: 100} - eventually(t, time.Second, func() bool { - rs := pty.resizeCalls() - return len(rs) == 1 && rs[0] == [2]uint16{30, 100} - }) - } - - func TestServeBuffersInputUntilAttachReady(t *testing.T) { -- src := &fakeSource{alive: true} - pty := newFakePTY() - spawnStarted := make(chan struct{}) - releaseSpawn := make(chan struct{}) -- spawn := func(context.Context, []string, []string, uint16, uint16) (ptyProcess, error) { -+ src := &fakeSource{alive: true, attachFn: func(context.Context, uint16, uint16) (ports.Stream, error) { - close(spawnStarted) - <-releaseSpawn - return pty, nil -- } -- mgr := NewManager(src, nil, testLogger(), WithSpawn(spawn), WithHeartbeat(0)) -+ }} -+ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) - defer mgr.Close() - - conn := newFakeConn() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go mgr.Serve(ctx, conn) - - conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} - select { - case <-spawnStarted: -@@ -141,53 +141,53 @@ func nextTerminal(t *testing.T, c *fakeConn) serverMsg { - t.Fatal("no frame within 1s") - return serverMsg{} - } - } - - // Opening a pane whose runtime is already dead must report exited without - // spawning an attach. The conn entry still has to clear so a later open for the - // same id on this connection is served instead of being silently dropped by the - // already-open guard. - func TestServeOpenDeadRuntimeReportsExitedAndAllowsReopen(t *testing.T) { -- src := &fakeSource{alive: false} - sp := &fakeSpawner{ptys: []*fakePTY{newFakePTY()}} -- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) -+ src := &fakeSource{alive: false, spawner: sp} -+ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) - defer mgr.Close() - - conn := newFakeConn() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go mgr.Serve(ctx, conn) - - conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} - if m := nextTerminal(t, conn); m.Type != msgExited { - t.Fatalf("first frame = %q, want exited", m.Type) - } - if got := sp.calls(); got != 0 { -- t.Fatalf("attach must never run against a dead runtime, got %d spawns", got) -+ t.Fatalf("attach must never run against a dead runtime, got %d attaches", got) - } - - src.setAlive(true) - conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} - if m := nextTerminal(t, conn); m.Type != msgOpened { - t.Fatalf("re-open frame = %q, want opened (open was dropped, entry stuck)", m.Type) - } - } - - // A session that exits after being opened must clear its connection entry on - // exit, so a later open for the same id is served rather than dropped by the - // already-open guard. - func TestServeExitAfterOpenClearsEntryAllowingReopen(t *testing.T) { -- src := &fakeSource{alive: true} // alive for the first attach - p := newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{p}} -- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) -+ src := &fakeSource{alive: true, spawner: sp} // alive for the first attach -+ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) - defer mgr.Close() - - conn := newFakeConn() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go mgr.Serve(ctx, conn) - - conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} - recv(t, conn, chTerminal, msgOpened, time.Second) - -@@ -202,61 +202,61 @@ func TestServeExitAfterOpenClearsEntryAllowingReopen(t *testing.T) { - - // An attachment that exits the moment it is opened (dead runtime) fires onExit - // from its run goroutine, racing the reopen that follows the exited frame. The - // identity-guarded delete in onExit must never evict a successor attachment - // registered under the same id: every reopen must be served (exited again for a - // still-dead runtime), never silently dropped by the already-open guard. - // Stressed across many iterations - // to shake the exit/reopen interleavings out. - func TestServeReopenAfterImmediateExitNeverStuck(t *testing.T) { - for i := 0; i < 400; i++ { -- src := &fakeSource{} -- src.setAlive(false) // dead runtime -> the open exits without attaching - sp := &fakeSpawner{} -- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) -+ src := &fakeSource{spawner: sp} -+ src.setAlive(false) // dead runtime -> the open exits without attaching -+ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) - - conn := newFakeConn() - ctx, cancel := context.WithCancel(context.Background()) - go mgr.Serve(ctx, conn) - - conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} - - recv(t, conn, chTerminal, msgExited, time.Second) - - // The reopen must be served even while the first open's exit teardown is - // still in flight. - conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} - recv(t, conn, chTerminal, msgExited, time.Second) - - cancel() - mgr.Close() - } - } - - func TestServeRejectsOpenWithoutID(t *testing.T) { -- mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) -+ mgr := NewManager(&fakeSource{}, nil, testLogger(), WithHeartbeat(0)) - defer mgr.Close() - conn := newFakeConn() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go mgr.Serve(ctx, conn) - - conn.in <- clientMsg{Ch: chTerminal, Type: msgOpen} - msg := recv(t, conn, chTerminal, msgError, time.Second) - if msg.Error == "" { - t.Fatal("expected an error message for open without id") - } - } - - func TestServeForwardsSessionChannelFromCDC(t *testing.T) { - bc := cdc.NewBroadcaster() -- mgr := NewManager(&fakeSource{}, bc, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) -+ mgr := NewManager(&fakeSource{}, bc, testLogger(), WithHeartbeat(0)) - defer mgr.Close() - - conn := newFakeConn() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go mgr.Serve(ctx, conn) - - conn.in <- clientMsg{Ch: chSubscribe, Type: msgSubscribe} - // Give the subscription time to register before publishing. - eventually(t, time.Second, func() bool { -@@ -264,84 +264,84 @@ func TestServeForwardsSessionChannelFromCDC(t *testing.T) { - select { - case m := <-conn.out: - return m.Ch == chSessions && m.Session != nil && m.Session.Seq == 9 - default: - return false - } - }) - } - - func TestServeSystemPingGetsPong(t *testing.T) { -- mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) -+ mgr := NewManager(&fakeSource{}, nil, testLogger(), WithHeartbeat(0)) - defer mgr.Close() - conn := newFakeConn() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go mgr.Serve(ctx, conn) - - conn.in <- clientMsg{Ch: chSystem, Type: msgPing} - recv(t, conn, chSystem, msgPong, time.Second) - } - - func TestServeHeartbeatPings(t *testing.T) { -- mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(10*time.Millisecond)) -+ mgr := NewManager(&fakeSource{}, nil, testLogger(), WithHeartbeat(10*time.Millisecond)) - defer mgr.Close() - conn := newFakeConn() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go mgr.Serve(ctx, conn) - - eventually(t, time.Second, func() bool { return atomic.LoadInt32(&conn.pings) >= 2 }) - } - - func TestServeClosesConnOnReadEnd(t *testing.T) { -- mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) -+ mgr := NewManager(&fakeSource{}, nil, testLogger(), WithHeartbeat(0)) - defer mgr.Close() - conn := newFakeConn() - ctx, cancel := context.WithCancel(context.Background()) - go mgr.Serve(ctx, conn) - - cancel() // client/server context ends - select { - case <-conn.closed: - case <-time.After(time.Second): - t.Fatal("Serve must close the conn when the context is cancelled") - } - } - - // Each connection opening the same pane gets its OWN attach PTY — that is the --// per-client model: zellij replays its init handshake + full repaint to every -+// per-client model: the runtime replays its init handshake + full repaint to every - // fresh attach, so no client depends on bytes another client consumed. Output - // pushed to one client's PTY must reach only that client, and closing one - // client's terminal must not touch the other's PTY. - func TestServePerClientAttachIsolation(t *testing.T) { -- src := &fakeSource{alive: true} - p1, p2 := newFakePTY(), newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{p1, p2}} -- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) -+ src := &fakeSource{alive: true, spawner: sp} -+ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) - defer mgr.Close() - - connA, connB := newFakeConn(), newFakeConn() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go mgr.Serve(ctx, connA) - go mgr.Serve(ctx, connB) - - connA.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} - recv(t, connA, chTerminal, msgOpened, time.Second) - eventually(t, time.Second, func() bool { return sp.calls() == 1 }) - - connB.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} - recv(t, connB, chTerminal, msgOpened, time.Second) - eventually(t, time.Second, func() bool { return sp.calls() == 2 }) - -- // Zellij fans output out per attach; here each fake PTY stands in for one -+ // The runtime fans output out per attach; here each fake PTY stands in for one - // attach process, so its bytes must reach exactly its own connection. - p1.push([]byte("for-A")) - data := recv(t, connA, chTerminal, msgData, time.Second) - got, _ := base64.StdEncoding.DecodeString(data.Data) - if string(got) != "for-A" { - t.Fatalf("conn A data = %q", got) - } - select { - case m := <-connB.out: - t.Fatalf("conn B received %s/%s for conn A's PTY output", m.Ch, m.Type) -@@ -361,46 +361,46 @@ func TestServePerClientAttachIsolation(t *testing.T) { - select { - case <-p2.closed: - t.Fatal("closing conn A's terminal must not close conn B's PTY") - default: - } - } - - // The open frame carries the client's grid; the PTY must start at that size - // rather than the kernel default, even though the attach is asynchronous. - func TestServeOpenAppliesInitialSize(t *testing.T) { -- src := &fakeSource{alive: true} - pty := newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{pty}} -- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) -+ src := &fakeSource{alive: true, spawner: sp} -+ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) - defer mgr.Close() - - conn := newFakeConn() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go mgr.Serve(ctx, conn) - - conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen, Rows: 40, Cols: 120} - recv(t, conn, chTerminal, msgOpened, time.Second) - eventually(t, time.Second, func() bool { - rs := pty.resizeCalls() - return len(rs) == 1 && rs[0] == [2]uint16{40, 120} - }) - } - - // Manager.Close must kill every live attach PTY: a PTY left open keeps its - // attach process running and deadlocks daemon shutdown. - func TestManagerCloseKillsLiveAttachments(t *testing.T) { -- src := &fakeSource{alive: true} - pty := newFakePTY() - sp := &fakeSpawner{ptys: []*fakePTY{pty}} -- mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) -+ src := &fakeSource{alive: true, spawner: sp} -+ mgr := NewManager(src, nil, testLogger(), WithHeartbeat(0)) - - conn := newFakeConn() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go mgr.Serve(ctx, conn) - - conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} - recv(t, conn, chTerminal, msgOpened, time.Second) - eventually(t, time.Second, func() bool { return sp.calls() == 1 }) - ---- Changes --- - -backend/internal/adapters/runtime/conpty/attach.go - @@ -0,0 +1,116 @@ - +// attach.go - conpty Attach: a loopback Stream over the B3 pty-host. Unlike - +// tmux/zellij, conpty does not spawn an attach CLI; it dials the session's - +// loopback host and speaks the B1 framing protocol directly. The host replays - +// the scrollback Snapshot as the first MsgTerminalData on connect, so a fresh - +// Read naturally yields the repaint first. - +package conpty - + - +import ( - + "context" - + "encoding/json" - + "fmt" - + "io" - + "sync" - + - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - +) - + - +var _ ports.Attacher = (*Runtime)(nil) - + - +// Attach opens a fresh attach Stream for the session by dialing its loopback - +// pty-host. rows/cols size the host's PTY from birth when known (a MsgResize is - +// sent right after connect). ctx cancellation closes the Stream. - +func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { - + sess := r.resolve(handle.ID) - + if sess == nil { - + return nil, fmt.Errorf("conpty: session %q not found", handle.ID) - + } - + conn, err := dialHost(sess.addr, dialTimeout) - + if err != nil { - + return nil, fmt.Errorf("conpty: dial host for %q: %w", handle.ID, err) - + } - + - + pr, pw := io.Pipe() - + s := &loopbackStream{conn: conn, pr: pr, pw: pw} - + - + // Pump host frames: MsgTerminalData payloads go into the pipe that Read - + // drains. The first such frame is the scrollback snapshot, so the replay - + // arrives before any live output. - + go s.pump() - + - + // ctx cancellation must terminate the stream (mirrors the unix/windows - + // spawn paths closing the PTY on ctx.Done). - + go func() { - + <-ctx.Done() - + _ = s.Close() - + }() - + - + if rows > 0 && cols > 0 { - + if err := s.Resize(rows, cols); err != nil { - + _ = s.Close() - + return nil, err - + } - + } - + return s, nil - +} - + - +// loopbackStream is a ports.Stream backed by a single loopback connection to the - +// pty-host. The pump goroutine reframes host output into an io.Pipe so Read - +// presents a plain byte stream; Write/Resize encode client frames onto the conn. - +type loopbackStream struct { - + conn io.ReadWriteCloser - + pr *io.PipeReader - + pw *io.PipeWriter - + - + closeOnce sync.Once - +} - + - +// pump reads framed host messages and writes MsgTerminalData payloads into the - +// pipe. It closes the pipe when the connection ends so Read returns EOF. - +func (s *loopbackStream) pump() { - + parser := NewMessageParser(func(msgType byte, payload []byte) { - + if msgType == MsgTerminalData { - + // Write blocks until Read drains, preserving back-pressure and order. - + _, _ = s.pw.Write(payload) - + } - + }) - + buf := make([]byte, 4096) - + for { - + n, err := s.conn.Read(buf) - + if n > 0 { - + parser.Feed(buf[:n]) - + } - + if err != nil { - + _ = s.pw.CloseWithError(err) - + return - + } - + } - +} - + - +func (s *loopbackStream) Read(p []byte) (int, error) { return s.pr.Read(p) } - + - +func (s *loopbackStream) Write(p []byte) (int, error) { - + if _, err := s.conn.Write(EncodeMessage(MsgTerminalInput, p)); err != nil { - + return 0, err - + } - + return len(p), nil - +} - + - +func (s *loopbackStream) Resize(rows, cols uint16) error { - + payload, _ := json.Marshal(ResizePayload{Cols: int(cols), Rows: int(rows)}) - ... (16 lines truncated) - +116 -0 - -backend/internal/adapters/runtime/conpty/attach_test.go - @@ -0,0 +1,151 @@ - +package conpty - + - +import ( - + "bytes" - + "context" - + "io" - + "testing" - + "time" - + - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - +) - + - +// runtimeForFixture wires a conpty Runtime to a running serveFixture by stuffing - +// the fixture's loopback addr into the session map under the given id, so Attach - +// resolves it without a real Windows spawn. - +func runtimeForFixture(id string, f *serveFixture) *Runtime { - + r := New(Options{}) - + r.mu.Lock() - + r.sessions[id] = &hostSession{addr: f.addr, pid: f.pty.PID()} - + r.mu.Unlock() - + return r - +} - + - +func readUntil(t *testing.T, s io.Reader, want string, timeout time.Duration) string { - + t.Helper() - + type res struct { - + out string - + } - + done := make(chan res, 1) - + go func() { - + var buf []byte - + tmp := make([]byte, 4096) - + for { - + n, err := s.Read(tmp) - + if n > 0 { - + buf = append(buf, tmp[:n]...) - + if bytes.Contains(buf, []byte(want)) { - + done <- res{string(buf)} - + return - + } - + } - + if err != nil { - + done <- res{string(buf)} - + return - + } - + } - + }() - + select { - + case r := <-done: - + return r.out - + case <-time.After(timeout): - + t.Fatalf("timed out reading for %q", want) - + return "" - + } - +} - + - +// TestAttachReplaysScrollback: the host sends the ring snapshot as the first - +// MsgTerminalData on connect, so a fresh Read on the Stream yields the replay. - +func TestAttachReplaysScrollback(t *testing.T) { - + f := startServe(t, 300) - + defer f.cancel() - + f.ring.Append([]byte("scrollback-line\n")) - + - + r := runtimeForFixture("sess", f) - + s, err := r.Attach(context.Background(), nameHandle("sess"), 0, 0) - + if err != nil { - + t.Fatalf("Attach: %v", err) - + } - + defer s.Close() - + - + out := readUntil(t, s, "scrollback-line", 2*time.Second) - + if !bytes.Contains([]byte(out), []byte("scrollback-line")) { - + t.Fatalf("scrollback not replayed on Read; got %q", out) - + } - +} - + - +// TestAttachWriteReachesPTY: Write on the Stream sends MsgTerminalInput, which - +// the host forwards to the fakePTY's input. - +func TestAttachWriteReachesPTY(t *testing.T) { - + f := startServe(t, 301) - + defer f.cancel() - + - + r := runtimeForFixture("sess", f) - + s, err := r.Attach(context.Background(), nameHandle("sess"), 0, 0) - + if err != nil { - + t.Fatalf("Attach: %v", err) - + } - + defer s.Close() - + - + keystrokes := []byte("ls -la\r") - + if _, err := s.Write(keystrokes); err != nil { - + t.Fatalf("Write: %v", err) - + } - + buf := make([]byte, len(keystrokes)) - + if _, err := io.ReadFull(f.pty.inR, buf); err != nil { - + t.Fatalf("read pty input: %v", err) - + } - + if string(buf) != string(keystrokes) { - + t.Fatalf("pty input = %q, want %q", buf, keystrokes) - + } - ... (51 lines truncated) - +151 -0 - -backend/internal/adapters/runtime/conpty/runtime.go - @@ -1,27 +1,27 @@ - -// runtime.go - conpty Runtime adapter. Implements ports.Runtime (minus Attach, - -// which comes in B5). Drives sessions via the B3 pty-host over loopback TCP, - -// using the B1 protocol and the B2 registry for restart recovery. - +// runtime.go - conpty Runtime adapter. Implements ports.Runtime and - +// ports.Attacher (see attach.go). Drives sessions via the B3 pty-host over - +// loopback TCP, using the B1 protocol and the B2 registry for restart recovery. - package conpty - - import ( - "context" - "fmt" - "regexp" - "sync" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - ) - - -// Ensure Runtime satisfies the port at compile time. Attach is added in B5. - +// Ensure Runtime satisfies the port at compile time (Attach in attach.go). - var _ ports.Runtime = (*Runtime)(nil) - - // validSessionID matches agent-orchestrator's assertValidSessionId. - var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - - // hostSession is the in-memory state for a live pty-host connection. - type hostSession struct { - addr string - pid int - } - +4 -4 - -backend/internal/adapters/runtime/ptyexec/spawn_unix.go - @@ -1,37 +1,42 @@ - -package terminal - +// Package ptyexec spawns a local PTY around an attach CLI (tmux/zellij) and - +// exposes it as a ports.Stream. It is the shared spawn the terminal layer used - +// to own directly; extracting it lets each runtime adapter back its Attach with - +// the same creack/pty (unix) or go-pty ConPTY (windows) plumbing. - +package ptyexec - - import ( - "context" - "errors" - "os" - "os/exec" - "sync" - "syscall" - "time" - - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/creack/pty" - ) - - -// defaultSpawn starts argv on a real PTY via creack/pty, sized rows×cols from - -// birth when a size is known: `zellij attach` reads the tty size once at - -// startup, and a post-spawn TIOCSWINSZ depends on SIGWINCH delivery that can - -// race the client installing its handler — StartWithSize makes the first read - -// correct by construction. env, when non-nil, replaces the inherited - -// environment (mirrors exec.Cmd.Env semantics). ctx cancellation closes the PTY - -// through the same graceful detach path as an explicit client close. Windows uses - -// a stub (see pty_windows.go) until a ConPTY path is added. - -func defaultSpawn(ctx context.Context, argv, env []string, rows, cols uint16) (ptyProcess, error) { - +// Spawn starts argv on a real PTY via creack/pty, sized rows×cols from birth - +// when a size is known: an attach client reads the tty size once at startup, and - +// a post-spawn TIOCSWINSZ depends on SIGWINCH delivery that can race the client - +// installing its handler — StartWithSize makes the first read correct by - +// construction. env, when non-nil, replaces the inherited environment (mirrors - +// exec.Cmd.Env semantics). ctx cancellation closes the PTY through the same - +// graceful detach path as an explicit client close. Windows uses a ConPTY path - +// (see spawn_windows.go). - +func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) { - if len(argv) == 0 { - - return nil, errors.New("terminal: empty attach command") - + return nil, errors.New("ptyexec: empty attach command") - } - if err := ctx.Err(); err != nil { - return nil, err - } - cmd := exec.Command(argv[0], argv[1:]...) - if env != nil { - cmd.Env = env - } - var f *os.File - var err error - @@ -58,42 +63,42 @@ type creackPTY struct { - - // reach a zellij client that missed or lost the original signal — the - + // reach an attach client that missed or lost the original signal — the - // session would stay laid out for a stale size, with no repaint until the - // next real change (the frontend re-sends its grid after each resize burst - // for exactly this self-heal; see useTerminalSession). The client re-reads - // the tty and re-reports to its server; when already in sync it's a no-op. - if p.cmd.Process != nil { - _ = p.cmd.Process.Signal(syscall.SIGWINCH) - } - return err - } - - // detachGrace is how long Close waits for a SIGTERM'd attach process to exit - -// on its own before falling back to SIGKILL. A zellij client that is being - +// on its own before falling back to SIGKILL. An attach client that is being - // drained detaches in ~50ms; the grace only runs out for a wedged process. - const detachGrace = 250 * time.Millisecond - - // Close stops the attach process and releases the PTY. - // - -// SIGTERM first, SIGKILL as fallback: a SIGTERM'd `zellij attach` deregisters - -// itself from the zellij server before exiting, while a SIGKILL'd one leaves - +// SIGTERM first, SIGKILL as fallback: a SIGTERM'd attach client deregisters - +// itself from its mux server before exiting, while a SIGKILL'd one leaves - // deregistration to the server noticing the dead socket. A dead-but-registered - -// client pins the session's size (zellij sizes a session to its smallest - +// client pins the session's size (a mux sizes a session to its smallest - // client), so the next attach renders for the ghost's grid — the "terminal - // doesn't repaint to the new size" desync. The master stays open through the - // grace so the run loop's copyOut keeps draining the client's shutdown output - // (a blocked tty write would stall the graceful exit past the grace). - // - // It is idempotent: both the attachment run loop (after copyOut returns) and - // attachment.close (via closeTerminal, conn cleanup, or Manager.Close) call - // Close on the same PTY, and cmd.Wait must run exactly once. A second - // concurrent Wait on the same process blocks forever, deadlocking daemon - // shutdown when a terminal is still attached. - +21 -16 - -backend/internal/adapters/runtime/ptyexec/spawn_unix_test.go - @@ -1,53 +1,53 @@ - -package terminal - +package ptyexec - - import ( - "context" - "strings" - "testing" - "time" - ) - - // TestCreackPTYCloseIsIdempotent guards the shutdown deadlock: the session run - // loop and session.close both call Close on the same PTY, so cmd.Wait must run - // exactly once. Without the sync.Once a second Wait blocks forever, so this test - // would hang (caught by the watchdog) rather than fail. - func TestCreackPTYCloseIsIdempotent(t *testing.T) { - - p, err := defaultSpawn(context.Background(), []string{"/bin/sh", "-c", "sleep 30"}, nil, 0, 0) - + p, err := Spawn(context.Background(), []string{"/bin/sh", "-c", "sleep 30"}, nil, 0, 0) - if err != nil { - t.Fatalf("spawn: %v", err) - } - - done := make(chan struct{}) - go func() { - _ = p.Close() - _ = p.Close() // second close must not block on a second cmd.Wait - close(done) - }() - - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("creackPTY.Close did not return: double Close deadlocked on cmd.Wait") - } - } - - // TestCreackPTYResizeSignalsOnIdenticalSize guards the resize self-heal: the - // kernel only raises SIGWINCH when TIOCSWINSZ actually changes the size, so a - -// re-asserted (identical) grid relies on Resize's explicit signal. A zellij - +// re-asserted (identical) grid relies on Resize's explicit signal. An attach - // client that lost the original update would otherwise keep its server laid - // out for a stale size forever — the "terminal doesn't repaint after resizing - // the pane" desync. - func TestCreackPTYResizeSignalsOnIdenticalSize(t *testing.T) { - - p, err := defaultSpawn(context.Background(), - + p, err := Spawn(context.Background(), - []string{"/bin/sh", "-c", `trap 'echo WINCHED' WINCH; while :; do sleep 0.05; done`}, nil, 0, 0) - if err != nil { - t.Fatalf("spawn: %v", err) - } - defer p.Close() - - // Give the shell a beat to install the trap, then resize twice to the SAME - // size. The first call changes the size (fresh PTYs start at 0x0) and the - // second is identical — only the explicit signal can deliver it. - time.Sleep(200 * time.Millisecond) - @@ -73,23 +73,23 @@ func TestCreackPTYResizeSignalsOnIdenticalSize(t *testing.T) { - -// zellij session at the previous client's size). - +// session at the previous client's size). - func TestCreackPTYSpawnsAtRequestedSize(t *testing.T) { - - p, err := defaultSpawn(context.Background(), []string{"/bin/sh", "-c", "stty size"}, nil, 40, 140) - + p, err := Spawn(context.Background(), []string{"/bin/sh", "-c", "stty size"}, nil, 40, 140) - if err != nil { - t.Fatalf("spawn: %v", err) - } - defer p.Close() - - deadline := time.Now().Add(5 * time.Second) - var out strings.Builder - buf := make([]byte, 4096) - for time.Now().Before(deadline) { - n, readErr := p.Read(buf) - @@ -100,40 +100,40 @@ func TestCreackPTYSpawnsAtRequestedSize(t *testing.T) { - -// chance to exit on SIGTERM (a zellij client deregisters from its server on - +// chance to exit on SIGTERM (an attach client deregisters from its server on - // SIGTERM; a straight SIGKILL leaves a ghost client that pins the session's - // size), and must still return promptly for a process that ignores SIGTERM. - func TestCreackPTYCloseTermsBeforeKill(t *testing.T) { - t.Run("cooperative process exits within the grace", func(t *testing.T) { - - p, err := defaultSpawn(context.Background(), - + p, err := Spawn(context.Background(), - []string{"/bin/sh", "-c", `trap 'exit 0' TERM; while :; do sleep 0.05; done`}, nil, 0, 0) - if err != nil { - t.Fatalf("spawn: %v", err) - } - time.Sleep(200 * time.Millisecond) // let the trap install - start := time.Now() - _ = p.Close() - if elapsed := time.Since(start); elapsed >= detachGrace { - t.Fatalf("Close took %v: SIGTERM path did not let a cooperative process exit before the kill grace", elapsed) - } - }) - - t.Run("TERM-ignoring process is killed after the grace", func(t *testing.T) { - - p, err := defaultSpawn(context.Background(), - + p, err := Spawn(context.Background(), - []string{"/bin/sh", "-c", `trap '' TERM; while :; do sleep 0.05; done`}, nil, 0, 0) - if err != nil { - t.Fatalf("spawn: %v", err) - } - time.Sleep(200 * time.Millisecond) - done := make(chan struct{}) - go func() { - _ = p.Close() - close(done) - }() - +9 -9 - -backend/internal/adapters/runtime/ptyexec/spawn_windows.go - @@ -1,38 +1,39 @@ - -package terminal - +package ptyexec - - import ( - "context" - "errors" - "sync" - "time" - - + "github.com/aoagents/agent-orchestrator/backend/internal/ports" - winpty "github.com/aymanbagabas/go-pty" - ) - - // detachGrace mirrors the Unix value: how long Close waits for the attach - // process to exit on its own (closing the ConPTY surfaces as EOF on the - // child's stdin) before falling back to Kill. - const detachGrace = 250 * time.Millisecond - - -// defaultSpawn starts argv on a Windows ConPTY and exposes the console pipes - -// through the same ptyProcess interface used by the Unix creack/pty path. - -// go-pty creates the pseudo-console at 80x25 internally, so we only Resize - -// when the caller actually has a grid (mirroring StartWithSize on Unix). - -// env, when non-nil, replaces the inherited environment via Win32's native - -// CreateProcess env block (mirrors exec.Cmd.Env semantics) — this is how a - -// per-session ZELLIJ_SOCKET_DIR reaches the zellij attach client. - -func defaultSpawn(ctx context.Context, argv []string, env []string, rows, cols uint16) (ptyProcess, error) { - +// Spawn starts argv on a Windows ConPTY and exposes the console pipes through - +// the same ports.Stream interface used by the Unix creack/pty path. go-pty - +// creates the pseudo-console at 80x25 internally, so we only Resize when the - +// caller actually has a grid (mirroring StartWithSize on Unix). env, when - +// non-nil, replaces the inherited environment via Win32's native CreateProcess - +// env block (mirrors exec.Cmd.Env semantics) — this is how a per-session - +// ZELLIJ_SOCKET_DIR reaches the zellij attach client. - +func Spawn(ctx context.Context, argv, env []string, rows, cols uint16) (ports.Stream, error) { - if len(argv) == 0 { - - return nil, errors.New("terminal: empty attach command") - + return nil, errors.New("ptyexec: empty attach command") - } - pty, err := winpty.New() - if err != nil { - return nil, err - } - if rows > 0 && cols > 0 { - if err := pty.Resize(int(cols), int(rows)); err != nil { - _ = pty.Close() - return nil, err - } - +11 -10 - -backend/internal/adapters/runtime/ptyexec/spawn_windows_test.go - @@ -1,68 +1,70 @@ - -package terminal - +package ptyexec - - import ( - "bytes" - "context" - -... (more changes truncated) - +1 -1 -[full diff: rtk git diff --no-compact] diff --git a/.superpowers/sdd/task-b6-brief.md b/.superpowers/sdd/task-b6-brief.md deleted file mode 100644 index 905ebcd4..00000000 --- a/.superpowers/sdd/task-b6-brief.md +++ /dev/null @@ -1,105 +0,0 @@ -# Task B6: select conpty on Windows, register pty-host subcommand, delete zellij - -## Goal -Final wiring of the migration: make Windows use the conpty runtime, register the -`ao pty-host` subcommand that conpty spawns, DELETE the zellij package, and clean up -the remaining zellij references (doctor, spawn hint, wiring test, the terminal attach -integration test). After this, the end state is: tmux on Darwin/Linux, conpty on -Windows, no zellij anywhere. Keep all three GOOS builds green and the full suite -passing on Darwin. - -Repo: `/Users/harshitsinghbhandari/Downloads/side-quests/rv-code/ReverbCode`, -module `backend/`, branch `migrate-zellij-to-tmux-conpty`. Module prefix -`github.com/aoagents/agent-orchestrator/backend`. - -## Current state (verified) -- `runtimeselect.New(log)` returns tmux on non-Windows, zellij on Windows. The union - interface already embeds ports.Attacher (Attach). Both tmux and conpty packages - build on all platforms (conpty's real ConPTY spawn is windows-tagged; a non-windows - stub errors). conpty.Runtime implements Create/Destroy/IsAlive/SendMessage/GetOutput - AND Attach (B5), so it satisfies the union. -- conpty's `defaultSpawnHost` (windows-tagged) spawns ` pty-host - ` and reads `READY: `. The - `conpty.RunHost(args []string, stdout io.Writer) int` entrypoint exists but is NOT - yet registered as a CLI subcommand. -- zellij is referenced outside its package by: `cli/spawn.go` (attach hint), - `cli/doctor.go` (version check), `runtimeselect/runtimeselect.go` (windows branch), - `daemon/wiring_test.go` (DefaultSocketDir test + zellij.New), `lifecycle_wiring.go` - (verify: likely a stale comment/import), and `terminal/attachment_integration_test.go` - (drives a REAL zellij pane). -- `internal/agentlaunch` is used by `cli/launch.go` (the `ao launch` trampoline) AND - zellij. After deleting zellij, agentlaunch is still used by cli/launch.go, so KEEP - agentlaunch. - -## Step 1 — switch runtimeselect to conpty on Windows -In `internal/adapters/runtime/runtimeselect/runtimeselect.go`: -- Windows branch returns `conpty.New(conpty.Options{})` (use its real default options; - check conpty.New's signature). Remove the zellij import, the DefaultSocketDir + - MkdirAll + warn block (conpty needs no socket dir). The `log` param may become - unused; if so, keep the signature (callers pass it) but use `_ = log` or drop the - param and update the one caller in daemon.go (prefer keeping the signature stable; - use `_ = log` with a short comment, or rename to `_`). -- Replace the `var _ Runtime = (*zellij.Runtime)(nil)` assertion with - `var _ Runtime = (*conpty.Runtime)(nil)`. Keep the tmux assertion. -- Update the package doc comment to "tmux on Darwin/Linux, conpty (ConPTY) on Windows". - -## Step 2 — register the `ao pty-host` subcommand -Find how existing hidden/internal subcommands are registered (look at -`internal/cli/launch.go` for the `ao launch` trampoline command and how -`internal/cli/root.go` wires subcommands). Add a `pty-host` command (mark it Hidden -like launch if launch is hidden) whose RunE calls -`conpty.RunHost(args, os.Stdout)` and exits with that code (use -`os.Exit(code)` or return an error that maps to the code; match how `launch` does -it). It takes raw positional args (sessionID, cwd, shellCmd, shellArg...). Disable -cobra flag parsing for these positionals if needed (e.g. `DisableFlagParsing: true` -or `Args: cobra.ArbitraryArgs`) so agent shell args with leading dashes are not -eaten. Confirm the arg order matches what conpty's `defaultSpawnHost` passes and what -`RunHost` expects (read both). - -## Step 3 — delete the zellij package -- `git rm -r internal/adapters/runtime/zellij`. -- Fix every now-broken reference: - - `cli/doctor.go`: remove the zellij version check (the `zellij.CheckVersionOutput`/ - `RequiredVersion` block). On Windows there is no external terminal-multiplexer tool - to check (ConPTY is built into the daemon binary). Either drop the Windows - terminal-tool check entirely, or emit a trivial pass like "ConPTY (built-in)". - Keep the tmux check on non-Windows unchanged. - - `cli/spawn.go`: the Windows attach hint used `zellij attach` + socket dir. conpty - has no user-facing attach command (the dashboard attaches over loopback). Change - the Windows hint to direct the user to the dashboard (e.g. "Attach from the AO - dashboard") or omit the attach line on Windows. Keep the non-Windows tmux hint - (`tmux attach -t `) unchanged. - - `daemon/wiring_test.go`: remove/replace the `zellij.New(...)` usages and the - `TestDaemonZellijSocketDir...` test. If a sub-test only validated zellij's - DefaultSocketDir helper (now deleted), delete that sub-test (the helper is gone). - For any test that needs a runtime, use `runtimeselect.New(nil)` or - `tmux.New(tmux.Options{})`. Do not weaken coverage of the wiring itself. - - `daemon/lifecycle_wiring.go`: remove any leftover zellij import/comment (the - startSession signature already takes `runtimeselect.Runtime`). - - `terminal/attachment_integration_test.go`: it currently spins up a real zellij - session to test the attach stream end to end. Re-point it at the **tmux** runtime - (available on this Darwin box) so the integration coverage survives: create a tmux - session via `tmux.New(...).Create(...)`, attach via the runtime's `Attach`, assert - the stream behavior, and clean up (kill the session in t.Cleanup). If re-pointing - is impractical, gate it on tmux availability with `exec.LookPath` and skip - otherwise, but prefer making it run against tmux. Keep the test's intent (it - proves the real attach stream streams a live pane and re-attaches at a new size). - -## Step 4 — verify nothing else imports zellij -`grep -rn "runtime/zellij" backend/` must return nothing. Also confirm -`internal/agentlaunch` is still imported by `cli/launch.go` (do NOT delete it). - -## Definition of done (from `backend/`) -- `grep -rn "runtime/zellij" .` returns nothing. -- `go build ./...` ; `GOOS=windows go build ./...` ; `GOOS=linux go build ./...` succeed. -- `go test -race ./...` passes (full suite). The tmux-backed attach integration test - runs (not skipped) and passes against real tmux 3.6b. -- `go vet ./...` clean. -- `grep -rn "—" ` shows no em dashes in your changes. - -## Hard rules -- This removes a whole package and rewires platform selection. Be surgical; keep the - union interface and all consumer interfaces intact (conpty already satisfies them). -- No new go.mod module. Never use em dashes ("—"). Minimal/idiomatic; mark shortcuts - with `ponytail:`. -- Commit on the branch; trailer `Co-Authored-By: Claude Opus 4.8 `. diff --git a/.superpowers/sdd/task-b6-report.md b/.superpowers/sdd/task-b6-report.md deleted file mode 100644 index 36d5dd97..00000000 --- a/.superpowers/sdd/task-b6-report.md +++ /dev/null @@ -1,107 +0,0 @@ -# Task B6 Report: select conpty on Windows, register pty-host, delete zellij - -## Status: DONE - -Commit: `6a18394` on branch `migrate-zellij-to-tmux-conpty`. - ---- - -## What was done - -### Step 1: runtimeselect switched to conpty on Windows - -`backend/internal/adapters/runtime/runtimeselect/runtimeselect.go`: -- Package doc updated to "tmux on Darwin/Linux, conpty (ConPTY) on Windows". -- Imports: dropped `zellij`, added `conpty`. -- Windows branch: removed `DefaultSocketDir` + `MkdirAll` + warn block; returns `conpty.New(conpty.Options{})`. -- `log` param renamed to `_` (signature kept stable for all callers; `_ *slog.Logger` signature preserved). -- Compile-time assertion changed from `(*zellij.Runtime)(nil)` to `(*conpty.Runtime)(nil)`. tmux assertion unchanged. - -### Step 2: `ao pty-host` subcommand registered - -New file `backend/internal/cli/ptyhost.go`: -- `newPtyHostCommand()` returns a hidden cobra command with `DisableFlagParsing: true` so agent shell args with leading dashes are not consumed by cobra. -- `RunE` calls `conpty.RunHost(args, os.Stdout)` and calls `os.Exit(code)` on non-zero return (matching the pattern of how launch handles its subprocess exit codes). -- Wired in `root.go` immediately after `newLaunchCommand(ctx)`. - -### Step 3: Zellij package deleted - -`git rm -r backend/internal/adapters/runtime/zellij` (6 files removed: commands.go, process\_other.go, process\_windows.go, zellij.go, zellij\_integration\_test.go, zellij\_test.go). - -Every reference fixed: - -**`cli/doctor.go`**: -- Removed `zellij` import. -- Replaced `checkTerminalRuntime`/`checkZellij` with a version that returns a static `doctorPass` on Windows ("ConPTY (built-in): no external terminal multiplexer required on Windows") and calls `checkTmux` on non-Windows. `checkTmux` function body unchanged. - -**`cli/spawn.go`**: -- Removed `zellij` import. -- Windows attach hint changed from `zellij attach + ZELLIJ_SOCKET_DIR` to "Attach from the AO dashboard (ConPTY sessions have no CLI attach command)". -- Non-Windows tmux hint unchanged. - -**`daemon/wiring_test.go`**: -- Replaced `zellij` import with `tmux`. -- `TestWiring_StartLifecycleThreadsMessengerIntoLCM`: `zellij.New(zellij.Options{})` replaced with `tmux.New(tmux.Options{})`. -- `TestDaemonZellijSocketDir_LeavesBudgetForSessionNames` deleted entirely (tested a helper that no longer exists). - -**`daemon/lifecycle_wiring.go`**: -- Stale comment "zellij.Runtime already implements this via SendMessage" updated to "Both tmux.Runtime and conpty.Runtime implement this via SendMessage". No import was present. - -### Step 4: Integration test re-pointed at tmux - -`backend/internal/terminal/attachment_integration_test.go` (was zellij-backed, now tmux-backed): -- Build tag `//go:build !windows` preserved. -- `zellij` import replaced with `tmux`. -- `TestAttachmentStreamsRealZellijPane` renamed `TestAttachmentStreamsRealTmuxPane`: creates a tmux session, writes via `rt.Create`, attaches via `rt.Attach` (through `newAttachment`), echoes a marker, verifies it appears in the stream, destroys, verifies `isExited()`. Session killed in `t.Cleanup`. -- `TestAttachmentReattachAdoptsNewSize` kept the same name and intent: client A at 37x115, detaches; client B at 40x148 re-attaches, issues `stty size`, asserts cols > 130. Timeout bumped from 5s to 10s for tmux startup. Session killed in `t.Cleanup`. -- Both tests skip if `tmux` is not in PATH (skip guard kept). - ---- - -## Verification outputs - -### grep -rn "runtime/zellij" . (from backend/) - -``` -(empty - exit code 1, no matches) -``` - -### go build ./... - -``` -Go build: Success -``` - -### GOOS=windows go build ./... - -``` -Go build: Success -``` - -### GOOS=linux go build ./... - -``` -Go build: Success -``` - -### go test -race ./... - -``` -Go test: 1607 passed in 77 packages -``` - -The tmux-backed integration tests ran (not skipped) and passed: -- `TestAttachmentStreamsRealTmuxPane` -- `TestAttachmentReattachAdoptsNewSize` - -### go vet ./... - -``` -Go vet: No issues found -``` - ---- - -## Concerns - -None. All changes are surgical; the union interface and all consumer interfaces are intact. `internal/agentlaunch` is still imported by `cli/launch.go` and was not touched. The remaining "zellij" strings in the codebase are comments only (in tmux.go, doc.go, conpty files, ports, agent adapters) and do not affect the build or runtime behavior. diff --git a/.superpowers/sdd/task-b6-review-package.txt b/.superpowers/sdd/task-b6-review-package.txt deleted file mode 100644 index e8a28512..00000000 --- a/.superpowers/sdd/task-b6-review-package.txt +++ /dev/null @@ -1,3502 +0,0 @@ -=== COMMITS === -6a18394 feat(runtime): select conpty on Windows, register pty-host subcommand... - -=== STAT === -.../runtime/runtimeselect/runtimeselect.go | 26 +- - .../internal/adapters/runtime/zellij/commands.go | 405 --------- - .../adapters/runtime/zellij/process_other.go | 18 - - .../adapters/runtime/zellij/process_windows.go | 79 -- - backend/internal/adapters/runtime/zellij/zellij.go | 964 --------------------- - .../runtime/zellij/zellij_integration_test.go | 165 ---- - .../adapters/runtime/zellij/zellij_test.go | 734 ---------------- - backend/internal/cli/doctor.go | 30 +- - backend/internal/cli/ptyhost.go | 29 + - backend/internal/cli/root.go | 1 + - backend/internal/cli/spawn.go | 8 +- - backend/internal/daemon/lifecycle_wiring.go | 2 +- - backend/internal/daemon/wiring_test.go | 23 +- - .../terminal/attachment_integration_test.go | 57 +- - 14 files changed, 68 insertions(+), 2473 deletions(-) -=== DIFF === -.../runtime/runtimeselect/runtimeselect.go | 26 +- - .../internal/adapters/runtime/zellij/commands.go | 405 --------- - .../adapters/runtime/zellij/process_other.go | 18 - - .../adapters/runtime/zellij/process_windows.go | 79 -- - backend/internal/adapters/runtime/zellij/zellij.go | 964 --------------------- - .../runtime/zellij/zellij_integration_test.go | 165 ---- - .../adapters/runtime/zellij/zellij_test.go | 734 ---------------- - backend/internal/cli/doctor.go | 30 +- - backend/internal/cli/ptyhost.go | 29 + - backend/internal/cli/root.go | 1 + - backend/internal/cli/spawn.go | 8 +- - backend/internal/daemon/lifecycle_wiring.go | 2 +- - backend/internal/daemon/wiring_test.go | 23 +- - .../terminal/attachment_integration_test.go | 57 +- - 14 files changed, 68 insertions(+), 2473 deletions(-) - -diff --git a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go -index 8e486db..3092590 100644 ---- a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go -+++ b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go -@@ -1,47 +1,37 @@ - // Package runtimeselect picks the correct runtime backend by platform: --// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). -+// tmux on Darwin/Linux, conpty (ConPTY) on Windows. - package runtimeselect - - import ( - "context" - "log/slog" -- "os" - "runtime" - -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" -- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - ) - --// Runtime is the union interface that both tmux and zellij satisfy. -+// Runtime is the union interface that both tmux and conpty satisfy. - // It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods - // the daemon wires directly, including ports.Attacher (Attach) so the terminal - // layer can open a Stream against the selected runtime. - type Runtime interface { - ports.Runtime // Create, Destroy, IsAlive - ports.Attacher - SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error - GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) - } - - // Compile-time assertions: both adapters must implement the union interface. - var _ Runtime = (*tmux.Runtime)(nil) --var _ Runtime = (*zellij.Runtime)(nil) -+var _ Runtime = (*conpty.Runtime)(nil) - --// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. --// log is used only for the Windows zellij socket-dir warning; nil is safe. --func New(log *slog.Logger) Runtime { -+// New returns the per-platform runtime: tmux on Darwin/Linux, conpty on Windows. -+// log is accepted for signature stability with callers but is currently unused. -+func New(_ *slog.Logger) Runtime { - if runtime.GOOS != "windows" { - return tmux.New(tmux.Options{}) - } -- // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. -- dir := zellij.DefaultSocketDir() -- if dir != "" { -- if err := os.MkdirAll(dir, 0o700); err != nil { -- if log != nil { -- log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) -- } -- } -- } -- return zellij.New(zellij.Options{SocketDir: dir}) -+ return conpty.New(conpty.Options{}) - } -diff --git a/backend/internal/adapters/runtime/zellij/commands.go b/backend/internal/adapters/runtime/zellij/commands.go -deleted file mode 100644 -index b410bd5..0000000 ---- a/backend/internal/adapters/runtime/zellij/commands.go -+++ /dev/null -@@ -1,405 +0,0 @@ --package zellij -- --import ( -- "encoding/base64" -- "encoding/binary" -- "fmt" -- "runtime" -- "sort" -- "strconv" -- "strings" -- "unicode/utf16" -- -- "github.com/aoagents/agent-orchestrator/backend/internal/ports" --) -- --const ( -- agentPaneName = "agent" -- defaultChunkBytes = 16 * 1024 --) -- --func versionArgs() []string { -- return []string{"--version"} --} -- --func createSessionArgs(id, layoutPath string) []string { -- clientOptions := embeddedClientOptions() -- args := make([]string, 0, 6+len(clientOptions)+6) -- args = append(args, -- "attach", "--create-background", id, -- "options", -- "--default-layout", layoutPath, -- ) -- args = append(args, clientOptions...) -- args = append(args, -- "--session-serialization", "false", -- "--show-startup-tips", "false", -- "--show-release-notes", "false", -- ) -- return args --} -- --func listPanesArgs(id string) []string { -- return []string{"--session", id, "action", "list-panes", "--all", "--json"} --} -- --func pasteArgs(id, paneID, chunk string) []string { -- if runtime.GOOS == "windows" { -- return []string{"--session", id, "action", "write-chars", "--pane-id", paneID, chunk} -- } -- return []string{"--session", id, "action", "paste", "--pane-id", paneID, chunk} --} -- --func sendEnterArgs(id, paneID string) []string { -- return []string{"--session", id, "action", "send-keys", "--pane-id", paneID, "Enter"} --} -- --func dumpScreenArgs(id, paneID string) []string { -- return []string{"--session", id, "action", "dump-screen", "--pane-id", paneID, "--full"} --} -- --func listSessionsArgs() []string { -- return []string{"list-sessions", "--no-formatting"} --} -- --// deleteSessionArgs builds the teardown command. `delete-session --force` --// kills a running session AND removes its serialized resurrection state in one --// step. Plain `kill-session` is not enough: zellij can keep the session in its --// global resurrection cache as "(EXITED - attach to resurrect)", and any later --// `zellij attach ` (e.g. the terminal mux re-opening a pane) would resurrect --// it — re-running the agent command for a session the daemon already destroyed. --func deleteSessionArgs(id string) []string { -- return []string{"delete-session", "--force", id} --} -- --func attachArgs(id string) []string { -- clientOptions := embeddedClientOptions() -- args := make([]string, 0, 3+len(clientOptions)) -- args = append(args, -- "attach", id, -- "options", -- ) -- args = append(args, clientOptions...) -- return args --} -- --func embeddedClientOptions() []string { -- return []string{ -- "--pane-frames", "false", -- // The dashboard terminal disables xterm local scrollback and lets the -- // embedded zellij client own scrollback. Keep mouse mode on so wheel -- // reports reach zellij, while leaving richer pointer behaviors off. -- "--mouse-mode", "true", -- "--advanced-mouse-actions", "false", -- "--mouse-hover-effects", "false", -- "--focus-follows-mouse", "false", -- "--mouse-click-through", "false", -- "--support-kitty-keyboard-protocol", "false", -- } --} -- --func handleIDValue(sessionID, paneID string) string { -- return sessionID + "/" + paneID --} -- --func terminalPaneID(id int) string { -- return fmt.Sprintf("terminal_%d", id) --} -- --func buildLayout(cfg ports.RuntimeConfig, shellPath string) string { -- if runtime.GOOS == "windows" { -- return directLayoutString(cfg.WorkspacePath, cfg.Argv) -- } -- spec := shellLaunchSpecFor(shellPath) -- shellCommand := shellLaunchCommand(cfg, spec) -- return layoutString(cfg.WorkspacePath, shellPath, spec.args, shellCommand) --} -- --// windowsLaunchArgv returns the argv zellij executes on Windows to start the --// agent. The trampoline reads the launch spec from AO_LAUNCH_SPEC, so KDL --// args quoting cannot mangle codex's `--config key=value` flags. --func windowsLaunchArgv(launcherBinary string) []string { -- command := launcherBinary -- if command == "" { -- command = "ao" -- } -- return []string{command, "launch"} --} -- --func windowsFallbackShellArgv(shellPath string) []string { -- if strings.TrimSpace(shellPath) == "" { -- shellPath = "powershell.exe" -- } -- base := strings.ToLower(filepathBase(shellPath)) -- if strings.Contains(base, "cmd") { -- return []string{shellPath, "/D", "/Q", "/K"} -- } -- if strings.Contains(base, "powershell") || strings.Contains(base, "pwsh") { -- return []string{shellPath, "-NoLogo", "-NoProfile", "-NoExit"} -- } -- if strings.Contains(base, "sh") { -- return []string{shellPath, "-i"} -- } -- return []string{shellPath} --} -- --// directLayoutString builds a layout that runs argv[0] with argv[1:] as zellij --// `args`, with no intermediate shell. Used on Windows where wrapping the agent --// in powershell/cmd quoting is unsound for arbitrary argv (e.g. codex's --// `--config key="value with spaces"`). --func directLayoutString(workspacePath string, argv []string) string { -- command := "" -- args := []string{} -- if len(argv) > 0 { -- command = argv[0] -- args = argv[1:] -- } -- -- var b strings.Builder -- b.WriteString("layout {\n") -- b.WriteString(" cwd ") -- b.WriteString(kdlQuote(workspacePath)) -- b.WriteString("\n") -- b.WriteString(" pane command=") -- b.WriteString(kdlQuote(command)) -- b.WriteString(" name=") -- b.WriteString(kdlQuote(agentPaneName)) -- b.WriteString(" borderless=true {\n") -- if len(args) > 0 { -- b.WriteString(" args ") -- b.WriteString(kdlJoin(args)) -- b.WriteString("\n") -- } -- b.WriteString(" }\n") -- b.WriteString("}\n") -- return b.String() --} -- --type shellLaunchSpec struct { -- args []string --} -- --func shellLaunchSpecFor(shellPath string) shellLaunchSpec { -- base := strings.ToLower(filepathBase(shellPath)) -- if strings.Contains(base, "cmd") { -- return shellLaunchSpec{args: []string{"/D", "/S", "/K"}} -- } -- if strings.Contains(base, "powershell") || strings.Contains(base, "pwsh") { -- return shellLaunchSpec{args: []string{"-NoLogo", "-NoProfile", "-NoExit", "-EncodedCommand"}} -- } -- return shellLaunchSpec{args: []string{"-lc"}} --} -- --func layoutString(workspacePath, shellPath string, shellArgs []string, shellCommand string) string { -- return "layout {\n" + -- " cwd " + kdlQuote(workspacePath) + "\n" + -- " pane command=" + kdlQuote(shellPath) + " name=" + kdlQuote(agentPaneName) + " borderless=true {\n" + -- " args " + kdlJoin(shellArgs) + " " + kdlQuote(shellCommand) + "\n" + -- " }\n" + -- "}\n" --} -- --func shellLaunchCommand(cfg ports.RuntimeConfig, spec shellLaunchSpec) string { -- if len(spec.args) > 0 && spec.args[0] == "-NoLogo" { -- return wrapLaunchCommandPowerShell(cfg) -- } -- if len(spec.args) > 0 && spec.args[0] == "/D" { -- return wrapLaunchCommandCmd(cfg) -- } -- return wrapLaunchCommandUnix(cfg) --} -- --func wrapLaunchCommandUnix(cfg ports.RuntimeConfig) string { -- path := cfg.Env["PATH"] -- if path == "" { -- path = getenv("PATH") -- } -- -- var b strings.Builder -- for _, key := range sortedKeys(cfg.Env) { -- if key == "PATH" { -- continue -- } -- b.WriteString("export ") -- b.WriteString(key) -- b.WriteString("=") -- b.WriteString(shellQuote(cfg.Env[key])) -- b.WriteString("; ") -- } -- if path != "" { -- b.WriteString("export PATH=") -- b.WriteString(shellQuote(path)) -- b.WriteString("; ") -- } -- b.WriteString(quoteArgvUnix(cfg.Argv)) -- return b.String() --} -- --func wrapLaunchCommandPowerShell(cfg ports.RuntimeConfig) string { -- path := cfg.Env["PATH"] -- if path == "" { -- path = getenv("PATH") -- } -- -- var b strings.Builder -- for _, key := range sortedKeys(cfg.Env) { -- if key == "PATH" { -- continue -- } -- b.WriteString("$env:") -- b.WriteString(key) -- b.WriteString(" = ") -- b.WriteString(psQuote(cfg.Env[key])) -- b.WriteString("; ") -- } -- if path != "" { -- b.WriteString("$env:PATH = ") -- b.WriteString(psQuote(path)) -- b.WriteString("; ") -- } -- b.WriteString(quoteArgvPowerShell(cfg.Argv)) -- return powerShellEncodedCommand(b.String()) --} -- --// powerShellEncodedCommand returns the base64'd UTF-16-LE form of script, --// suitable for `powershell.exe -EncodedCommand`. zellij's KDL `args` quoting --// is not robust enough to round-trip arbitrary PowerShell script text through --// a plain `-Command` argv slot, so we hand PowerShell a single opaque base64 --// blob instead. --func powerShellEncodedCommand(script string) string { -- words := utf16.Encode([]rune(script)) -- buf := make([]byte, len(words)*2) -- for i, word := range words { -- binary.LittleEndian.PutUint16(buf[i*2:], word) -- } -- return base64.StdEncoding.EncodeToString(buf) --} -- --func wrapLaunchCommandCmd(cfg ports.RuntimeConfig) string { -- path := cfg.Env["PATH"] -- if path == "" { -- path = getenv("PATH") -- } -- -- var b strings.Builder -- for _, key := range sortedKeys(cfg.Env) { -- if key == "PATH" { -- continue -- } -- b.WriteString("set \"") -- b.WriteString(key) -- b.WriteString("=") -- b.WriteString(cmdQuote(cfg.Env[key])) -- b.WriteString("\" && ") -- } -- if path != "" { -- b.WriteString("set \"PATH=") -- b.WriteString(cmdQuote(path)) -- b.WriteString("\" && ") -- } -- b.WriteString(quoteArgvCmd(cfg.Argv)) -- return b.String() --} -- --func validateEnvKeys(env map[string]string) error { -- for key := range env { -- if !validEnvKey(key) { -- return fmt.Errorf("zellij runtime: invalid env key %q", key) -- } -- } -- return nil --} -- --func validEnvKey(key string) bool { -- if key == "" { -- return false -- } -- for i, r := range key { -- if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { -- continue -- } -- if i > 0 && r >= '0' && r <= '9' { -- continue -- } -- return false -- } -- return true --} -- --func sortedKeys(m map[string]string) []string { -- keys := make([]string, 0, len(m)) -- for k := range m { -- keys = append(keys, k) -- } -- sort.Strings(keys) -- return keys --} -- --func shellQuote(s string) string { -- return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" --} -- --func psQuote(s string) string { -- return "'" + strings.ReplaceAll(s, "'", "''") + "'" --} -- --func cmdQuote(s string) string { -- return strings.ReplaceAll(s, "\"", "\"\"") --} -- --// quoteArgvUnix renders argv as a POSIX-shell command, single-quoting each --// argument so a value with spaces stays one word under `sh -lc`. --func quoteArgvUnix(argv []string) string { -- parts := make([]string, len(argv)) -- for i, a := range argv { -- parts[i] = shellQuote(a) -- } -- return strings.Join(parts, " ") --} -- --// quoteArgvPowerShell renders argv for `powershell -Command`. The call operator --// `&` is required so a quoted first token is invoked as a command rather than --// echoed as a string literal. --func quoteArgvPowerShell(argv []string) string { -- if len(argv) == 0 { -- return "" -- } -- parts := make([]string, len(argv)) -- for i, a := range argv { -- parts[i] = psQuote(a) -- } -- return "& " + strings.Join(parts, " ") --} -- --// quoteArgvCmd renders argv for cmd.exe, wrapping each argument in double quotes --// (doubling any embedded quote) so spaces don't split a single argument. --func quoteArgvCmd(argv []string) string { -- parts := make([]string, len(argv)) -- for i, a := range argv { -- parts[i] = "\"" + strings.ReplaceAll(a, "\"", "\"\"") + "\"" -- } -- return strings.Join(parts, " ") --} -- --func kdlQuote(s string) string { -- return strconv.Quote(s) --} -- --func kdlJoin(args []string) string { -- parts := make([]string, 0, len(args)) -- for _, arg := range args { -- parts = append(parts, kdlQuote(arg)) -- } -- return strings.Join(parts, " ") --} -- --func filepathBase(path string) string { -- if path == "" { -- return "" -- } -- i := strings.LastIndexAny(path, `/\`) -- if i < 0 { -- return path -- } -- return path[i+1:] --} -diff --git a/backend/internal/adapters/runtime/zellij/process_other.go b/backend/internal/adapters/runtime/zellij/process_other.go -deleted file mode 100644 -index 69e955f..0000000 ---- a/backend/internal/adapters/runtime/zellij/process_other.go -+++ /dev/null -@@ -1,18 +0,0 @@ --//go:build !windows -- --package zellij -- --import ( -- "errors" -- "os/exec" --) -- --// hideWindow is a no-op off Windows: only Windows pops a console window. --func hideWindow(*exec.Cmd) {} -- --// startBackgroundProcess is a stub: the fire-and-forget path is only used by --// the Windows zellij codepath. Non-Windows builds create sessions --// synchronously via runner.Run. --func startBackgroundProcess(env []string, name string, args ...string) error { -- return errors.New("zellij runtime: background spawn is windows-only") --} -diff --git a/backend/internal/adapters/runtime/zellij/process_windows.go b/backend/internal/adapters/runtime/zellij/process_windows.go -deleted file mode 100644 -index 5130196..0000000 ---- a/backend/internal/adapters/runtime/zellij/process_windows.go -+++ /dev/null -@@ -1,79 +0,0 @@ --//go:build windows -- --package zellij -- --import ( -- "os/exec" -- "strings" -- "syscall" -- -- "golang.org/x/sys/windows" --) -- --// hideWindow suppresses the console window for a console-subsystem child so --// frequent zellij calls (e.g. the reaper's list-sessions) don't flash on Windows. --func hideWindow(cmd *exec.Cmd) { -- cmd.SysProcAttr = &syscall.SysProcAttr{ -- HideWindow: true, -- CreationFlags: windows.CREATE_NO_WINDOW, -- } --} -- --func startBackgroundProcess(env []string, name string, args ...string) error { -- script := "Start-Process -FilePath " + psQuote(name) + " -ArgumentList " + psQuote(windowsCommandLine(args)) + " -WindowStyle Hidden" -- cmd := exec.Command("powershell.exe", "-NoLogo", "-NoProfile", "-EncodedCommand", powerShellEncodedCommand(script)) -- cmd.Env = env -- cmd.SysProcAttr = &syscall.SysProcAttr{ -- // CREATE_NO_WINDOW, not CREATE_NEW_CONSOLE: the latter creates a console -- // then hides it, which flashed a window. -- CreationFlags: windows.CREATE_NO_WINDOW, -- HideWindow: true, -- } -- if err := cmd.Start(); err != nil { -- return err -- } -- go func() { _ = cmd.Wait() }() -- return nil --} -- --func windowsCommandLine(args []string) string { -- quoted := make([]string, len(args)) -- for i, arg := range args { -- quoted[i] = windowsQuoteArg(arg) -- } -- return strings.Join(quoted, " ") --} -- --func windowsQuoteArg(arg string) string { -- if arg == "" { -- return `""` -- } -- if !strings.ContainsAny(arg, " \t\"") { -- return arg -- } -- -- var b strings.Builder -- b.WriteByte('"') -- backslashes := 0 -- for _, r := range arg { -- switch r { -- case '\\': -- backslashes++ -- case '"': -- b.WriteString(strings.Repeat(`\`, backslashes*2+1)) -- b.WriteRune(r) -- backslashes = 0 -- default: -- if backslashes > 0 { -- b.WriteString(strings.Repeat(`\`, backslashes)) -- backslashes = 0 -- } -- b.WriteRune(r) -- } -- } -- if backslashes > 0 { -- b.WriteString(strings.Repeat(`\`, backslashes*2)) -- } -- b.WriteByte('"') -- return b.String() --} -diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go -deleted file mode 100644 -index d7a8451..0000000 ---- a/backend/internal/adapters/runtime/zellij/zellij.go -+++ /dev/null -@@ -1,964 +0,0 @@ --// 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/adapters/runtime/ptyexec" -- "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) --var _ ports.Attacher = (*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")) --} -- --// Attach opens a fresh attach Stream by spawning the zellij attach client on a --// local PTY, sized rows x cols from birth when known. On Windows the per-session --// env block (ZELLIJ_SOCKET_DIR) is applied to the child. ctx cancellation closes --// the PTY. --func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { -- argv, env, err := r.attachCommand(handle) -- if err != nil { -- return nil, err -- } -- return ptyexec.Spawn(ctx, argv, env, rows, cols) --} -- --// 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 } -diff --git a/backend/internal/adapters/runtime/zellij/zellij_integration_test.go b/backend/internal/adapters/runtime/zellij/zellij_integration_test.go -deleted file mode 100644 -index ac6a33e..0000000 ---- a/backend/internal/adapters/runtime/zellij/zellij_integration_test.go -+++ /dev/null -@@ -1,165 +0,0 @@ --package zellij -- --import ( -- "context" -- "os" -- "os/exec" -- "path/filepath" -- "runtime" -- "strings" -- "testing" -- "time" -- -- "github.com/aoagents/agent-orchestrator/backend/internal/ports" --) -- --func TestRuntimeIntegration(t *testing.T) { -- if _, err := exec.LookPath("zellij"); err != nil { -- t.Skip("zellij unavailable") -- } -- -- ctx := context.Background() -- id := "ao_itest_zj" -- socketDir := tempSocketDir(t, "ao-zj-itest-") -- configDir := t.TempDir() -- opts := Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: configDir} -- if runtime.GOOS == "windows" { -- opts.Timeout = 30 * time.Second -- opts.LauncherBinary = buildAOForIntegration(t) -- opts.Shell = "cmd.exe" -- } -- r := New(opts) -- _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) -- argv := []string{"sh", "-lc", "printf ready-$AO_SESSION_ID\\n; exec sh -i"} -- sendCommand := "echo hello-from-zellij" -- if runtime.GOOS == "windows" { -- argv = []string{"cmd.exe", "/D", "/Q", "/K", "echo ready-%AO_SESSION_ID%"} -- } -- -- h, err := r.Create(ctx, ports.RuntimeConfig{ -- SessionID: "ao_itest_zj", -- WorkspacePath: t.TempDir(), -- Argv: argv, -- Env: map[string]string{"AO_SESSION_ID": id}, -- }) -- if err != nil { -- t.Fatalf("Create: %v", err) -- } -- defer r.Destroy(ctx, h) -- -- alive, err := r.IsAlive(ctx, h) -- if err != nil { -- t.Fatalf("IsAlive: %v", err) -- } -- if !alive { -- t.Fatal("alive = false, want true") -- } -- prefixAlive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: "ao_itest"}) -- if err != nil { -- t.Fatalf("IsAlive prefix: %v", err) -- } -- if prefixAlive { -- t.Fatal("prefix handle reported alive; zellij session matching is not exact") -- } -- -- out := waitForRuntimeOutput(t, r, h, "ready-") -- if !strings.Contains(out, "ready-") { -- t.Fatalf("output = %q, want ready output", out) -- } -- if err := r.SendMessage(ctx, h, sendCommand); err != nil { -- t.Fatalf("SendMessage: %v", err) -- } -- out = waitForRuntimeOutput(t, r, h, "hello-from-zellij") -- if !strings.Contains(out, "hello-from-zellij") { -- t.Fatalf("output = %q, want sent command output", out) -- } -- -- if err := r.Destroy(ctx, h); err != nil { -- t.Fatalf("Destroy: %v", err) -- } -- alive, err = r.IsAlive(ctx, h) -- if err != nil { -- t.Fatalf("IsAlive after destroy: %v", err) -- } -- if alive { -- t.Fatal("alive after destroy = true, want false") -- } --} -- --func waitForRuntimeOutput(t *testing.T, r *Runtime, h ports.RuntimeHandle, want string) string { -- t.Helper() -- deadline := time.Now().Add(3 * time.Second) -- var out string -- for time.Now().Before(deadline) { -- var err error -- out, err = r.GetOutput(context.Background(), h, 30) -- if err != nil { -- t.Fatalf("GetOutput: %v", err) -- } -- if strings.Contains(out, want) { -- return out -- } -- time.Sleep(100 * time.Millisecond) -- } -- return out --} -- --func buildAOForIntegration(t *testing.T) string { -- t.Helper() -- out := filepath.Join(t.TempDir(), "ao.exe") -- cmd := exec.Command("go", "build", "-o", out, "./cmd/ao") -- cmd.Dir = filepath.Clean(filepath.Join("..", "..", "..", "..")) -- if raw, err := cmd.CombinedOutput(); err != nil { -- t.Fatalf("build ao test launcher: %v: %s", err, strings.TrimSpace(string(raw))) -- } -- return out --} -- --func tempSocketDir(t *testing.T, pattern string) string { -- t.Helper() -- parent := os.TempDir() -- if runtime.GOOS != "windows" { -- parent = "/tmp" -- } -- socketDir, err := os.MkdirTemp(parent, pattern) -- if err != nil { -- t.Fatalf("mkdir socket dir: %v", err) -- } -- t.Cleanup(func() { _ = os.RemoveAll(socketDir) }) -- return socketDir --} -- --func TestRuntimeIntegrationUsesExactSessionParsing(t *testing.T) { -- if _, err := exec.LookPath("zellij"); err != nil { -- t.Skip("zellij unavailable") -- } -- if runtime.GOOS == "windows" { -- t.Skip("exact session parsing is covered by TestRuntimeIntegration on Windows") -- } -- -- ctx := context.Background() -- socketDir := tempSocketDir(t, "ao-zj-exact-itest-") -- r := New(Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: t.TempDir()}) -- longID := "ao_zj_exact_long" -- prefixID := "ao_zj_exact" -- _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) -- _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID}) -- -- h, err := r.Create(ctx, ports.RuntimeConfig{ -- SessionID: "ao_zj_exact_long", -- WorkspacePath: t.TempDir(), -- Argv: []string{"sh", "-lc", "printf ready\\n; exec sh -i"}, -- }) -- if err != nil { -- t.Fatalf("Create: %v", err) -- } -- defer r.Destroy(ctx, h) -- -- alive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID}) -- if err != nil { -- t.Fatalf("IsAlive prefix: %v", err) -- } -- if alive { -- t.Fatal("prefix handle reported alive; zellij session matching is not exact") -- } --} -diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go -deleted file mode 100644 -index 1a6d6a8..0000000 ---- a/backend/internal/adapters/runtime/zellij/zellij_test.go -+++ /dev/null -@@ -1,734 +0,0 @@ --package zellij -- --import ( -- "context" -- "errors" -- "os/exec" -- "reflect" -- "runtime" -- "strings" -- "testing" -- "time" -- -- "github.com/aoagents/agent-orchestrator/backend/internal/domain" -- "github.com/aoagents/agent-orchestrator/backend/internal/ports" --) -- --func TestNewDefaultsToPortableShell(t *testing.T) { -- t.Setenv("SHELL", "") -- r := New(Options{}) -- want := "/bin/sh" -- if runtime.GOOS == "windows" { -- want = "powershell.exe" -- } -- if got := r.shell; got != want { -- t.Fatalf("default shell = %q, want %q", got, want) -- } --} -- --func TestZellijCommandEnvNormalizesBrowserTerminalColors(t *testing.T) { -- got := zellijCommandEnv( -- []string{"NO_COLOR=1", "TERM=dumb", "COLORTERM=", "KEEP=yes"}, -- []string{"ZELLIJ_SOCKET_DIR=/tmp/zj"}, -- ) -- -- if runtime.GOOS == "windows" { -- if !contains(got, "NO_COLOR=1") { -- t.Fatalf("windows env = %#v, want NO_COLOR preserved", got) -- } -- return -- } -- -- if containsKey(got, "NO_COLOR") { -- t.Fatalf("NO_COLOR survived env normalization: %#v", got) -- } -- for _, want := range []string{"KEEP=yes", "TERM=xterm-256color", "COLORTERM=truecolor", "ZELLIJ_SOCKET_DIR=/tmp/zj"} { -- if !contains(got, want) { -- t.Fatalf("env missing %q in %#v", want, got) -- } -- } --} -- --func expectedZellijEnv(socketDir string) []string { -- env := []string{} -- if runtime.GOOS != "windows" { -- env = append(env, "TERM=xterm-256color", "COLORTERM=truecolor") -- } -- if socketDir != "" { -- env = append(env, "ZELLIJ_SOCKET_DIR="+socketDir) -- } -- return env --} -- --func expectedAttachEnvPrefix() []string { -- if runtime.GOOS == "windows" { -- return []string{} -- } -- return []string{"env", "-u", "NO_COLOR", "TERM=xterm-256color", "COLORTERM=truecolor"} --} -- --func contains(values []string, want string) bool { -- for _, value := range values { -- if value == want { -- return true -- } -- } -- return false --} -- --func containsKey(values []string, key string) bool { -- prefix := key + "=" -- for _, value := range values { -- if strings.HasPrefix(value, prefix) { -- return true -- } -- } -- return false --} -- --func TestCommandBuilders(t *testing.T) { -- embeddedOptions := []string{ -- "--pane-frames", "false", -- "--mouse-mode", "true", -- "--advanced-mouse-actions", "false", -- "--mouse-hover-effects", "false", -- "--focus-follows-mouse", "false", -- "--mouse-click-through", "false", -- "--support-kitty-keyboard-protocol", "false", -- } -- if got, want := versionArgs(), []string{"--version"}; !reflect.DeepEqual(got, want) { -- t.Fatalf("versionArgs = %#v, want %#v", got, want) -- } -- wantCreate := append([]string{"attach", "--create-background", "sess-1", "options", "--default-layout", "/tmp/layout.kdl"}, embeddedOptions...) -- wantCreate = append(wantCreate, "--session-serialization", "false", "--show-startup-tips", "false", "--show-release-notes", "false") -- if got, want := createSessionArgs("sess-1", "/tmp/layout.kdl"), wantCreate; !reflect.DeepEqual(got, want) { -- t.Fatalf("createSessionArgs = %#v, want %#v", got, want) -- } -- if got, want := listPanesArgs("sess-1"), []string{"--session", "sess-1", "action", "list-panes", "--all", "--json"}; !reflect.DeepEqual(got, want) { -- t.Fatalf("listPanesArgs = %#v, want %#v", got, want) -- } -- pasteAction := "paste" -- if runtime.GOOS == "windows" { -- pasteAction = "write-chars" -- } -- if got, want := pasteArgs("sess-1", "terminal_0", "hello"), []string{"--session", "sess-1", "action", pasteAction, "--pane-id", "terminal_0", "hello"}; !reflect.DeepEqual(got, want) { -- t.Fatalf("pasteArgs = %#v, want %#v", got, want) -- } -- if got, want := dumpScreenArgs("sess-1", "terminal_0"), []string{"--session", "sess-1", "action", "dump-screen", "--pane-id", "terminal_0", "--full"}; !reflect.DeepEqual(got, want) { -- t.Fatalf("dumpScreenArgs = %#v, want %#v", got, want) -- } -- // delete-session --force (not kill-session): teardown must also purge the -- // serialized resurrection state, or a later `zellij attach` re-creates the -- // session — and re-runs its agent — after the daemon destroyed it. -- if got, want := deleteSessionArgs("sess-1"), []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { -- t.Fatalf("deleteSessionArgs = %#v, want %#v", got, want) -- } -- wantAttach := append([]string{"attach", "sess-1", "options"}, embeddedOptions...) -- if got, want := attachArgs("sess-1"), wantAttach; !reflect.DeepEqual(got, want) { -- t.Fatalf("attachArgs = %#v, want %#v", got, want) -- } --} -- --func TestZellijSessionNameSanitizesIssueRefs(t *testing.T) { -- got, err := zellijSessionName("repo/issue#42.1") -- if err != nil { -- t.Fatalf("zellijSessionName: %v", err) -- } -- if err := validateSessionID(got); err != nil { -- t.Fatalf("sanitized id %q is invalid: %v", got, err) -- } -- if !strings.HasPrefix(got, "repo-issue-42-1-") { -- t.Fatalf("sanitized id = %q, want readable prefix", got) -- } -- if got == "repo/issue#42.1" { -- t.Fatal("sanitized id still contains raw unsafe characters") -- } --} -- --// SessionName must return the exact name Create registers a session under, so --// callers that print an attach hint (e.g. `ao spawn`) reference the real --// session. A short, conforming id passes through; a long one is sanitised to a --// different name — printing the raw id there would send users to a missing --// session. --func TestSessionNameMatchesCreateNaming(t *testing.T) { -- short := "myproj-1" -- if got := SessionName(short); got != short { -- t.Fatalf("SessionName(%q) = %q, want it unchanged", short, got) -- } -- -- long := domain.SessionID(strings.Repeat("x", 60) + "-1") -- viaCreate, err := zellijSessionName(long) -- if err != nil { -- t.Fatalf("zellijSessionName: %v", err) -- } -- if got := SessionName(string(long)); got != viaCreate { -- t.Fatalf("SessionName = %q, but Create uses %q", got, viaCreate) -- } -- if SessionName(string(long)) == string(long) { -- t.Fatal("expected a long id to be sanitised to a different name") -- } --} -- --func TestValidateSessionAndPaneID(t *testing.T) { -- for _, id := range []string{"sess-1", "S_2", "abc123"} { -- if err := validateSessionID(id); err != nil { -- t.Fatalf("validateSessionID(%q): %v", id, err) -- } -- } -- for _, id := range []string{"", "sess.1", "sess/1", "$(boom)", "with space"} { -- if err := validateSessionID(id); err == nil { -- t.Fatalf("validateSessionID(%q): got nil, want error", id) -- } -- } -- for _, id := range []string{"terminal_0", "terminal_42"} { -- if err := validatePaneID(id); err != nil { -- t.Fatalf("validatePaneID(%q): %v", id, err) -- } -- } -- for _, id := range []string{"", "0", "plugin_0", "terminal_x", "terminal_1/2"} { -- if err := validatePaneID(id); err == nil { -- t.Fatalf("validatePaneID(%q): got nil, want error", id) -- } -- } --} -- --func TestHandleID(t *testing.T) { -- session, pane, err := handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7"}) -- if err != nil { -- t.Fatalf("handleID: %v", err) -- } -- if session != "sess-1" || pane != "terminal_7" { -- t.Fatalf("handleID = %q/%q", session, pane) -- } --} -- --func TestBuildLayoutExportsEnvAndRunsAgentCommand(t *testing.T) { -- oldGetenv := getenv -- getenv = func(key string) string { -- if key == "PATH" { -- return "/usr/bin:/bin" -- } -- return "" -- } -- defer func() { getenv = oldGetenv }() -- -- got := buildLayout(ports.RuntimeConfig{WorkspacePath: "/tmp/ws", Argv: []string{"ao", "run"}, Env: map[string]string{ -- "AO_SESSION_ID": "sess-1", -- "ODD": "can't", -- "PATH": "/custom/bin:/usr/bin", -- }}, "/bin/zsh") -- if runtime.GOOS == "windows" { -- for _, want := range []string{ -- `cwd "/tmp/ws"`, -- `pane command="ao" name="agent" borderless=true`, -- `args "run"`, -- } { -- if !strings.Contains(got, want) { -- t.Fatalf("direct windows layout missing %q in %q", want, got) -- } -- } -- return -- } -- -- for _, want := range []string{ -- `cwd "/tmp/ws"`, -- `pane command="/bin/zsh" name="agent" borderless=true`, -- "export AO_SESSION_ID='sess-1';", -- "export ODD='can'\\\\''t';", -- "export PATH='/custom/bin:/usr/bin';", -- "'ao' 'run'", -- } { -- if !strings.Contains(got, want) { -- t.Fatalf("layout missing %q in %q", want, got) -- } -- } -- if strings.Contains(got, "exec '/bin/zsh' -i") { -- t.Fatalf("layout kept pane alive after agent exit: %q", got) -- } --} -- --func TestBuildLayoutUsesPowerShellLaunchOnWindowsShells(t *testing.T) { -- oldGetenv := getenv -- getenv = func(key string) string { -- if key == "PATH" { -- return `C:\custom\bin` -- } -- return "" -- } -- defer func() { getenv = oldGetenv }() -- -- got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, Argv: []string{"Write-Host", "ready"}, Env: map[string]string{ -- "AO_SESSION_ID": "sess-1", -- }}, `C:\Program Files\PowerShell\7\pwsh.exe`) -- if runtime.GOOS == "windows" { -- for _, want := range []string{ -- `cwd "C:\\ws"`, -- `pane command="Write-Host" name="agent" borderless=true`, -- `args "ready"`, -- } { -- if !strings.Contains(got, want) { -- t.Fatalf("direct windows layout missing %q in %q", want, got) -- } -- } -- return -- } -- -- for _, want := range []string{ -- `pane command="C:\\Program Files\\PowerShell\\7\\pwsh.exe" name="agent" borderless=true`, -- `args "-NoLogo" "-NoProfile" "-NoExit" "-EncodedCommand"`, -- } { -- if !strings.Contains(got, want) { -- t.Fatalf("powershell layout missing %q in %q", want, got) -- } -- } --} -- --func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) { -- oldGetenv := getenv -- getenv = func(key string) string { -- return "" -- } -- defer func() { getenv = oldGetenv }() -- -- got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, Argv: []string{"echo", "ready"}, Env: map[string]string{ -- "AO_SESSION_ID": "sess-1", -- }}, `C:\Windows\System32\cmd.exe`) -- if runtime.GOOS == "windows" { -- for _, want := range []string{ -- `cwd "C:\\ws"`, -- `pane command="echo" name="agent" borderless=true`, -- `args "ready"`, -- } { -- if !strings.Contains(got, want) { -- t.Fatalf("direct windows layout missing %q in %q", want, got) -- } -- } -- return -- } -- -- for _, want := range []string{ -- `pane command="C:\\Windows\\System32\\cmd.exe" name="agent" borderless=true`, -- `args "/D" "/S" "/K"`, -- `AO_SESSION_ID=sess-1`, -- `\"echo\" \"ready\"`, -- } { -- if !strings.Contains(got, want) { -- t.Fatalf("cmd layout missing %q in %q", want, got) -- } -- } --} -- --func TestCreateRejectsInvalidEnvKeys(t *testing.T) { -- r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh"}) -- r.runner = &fakeRunner{} -- _, err := r.Create(context.Background(), ports.RuntimeConfig{ -- SessionID: "sess-1", -- WorkspacePath: "/tmp/ws", -- Argv: []string{"echo", "ready"}, -- Env: map[string]string{"BAD KEY": "x"}, -- }) -- if err == nil || !strings.Contains(err.Error(), "invalid env key") { -- t.Fatalf("Create err = %v, want invalid env key", err) -- } --} -- --func TestCreateStartsSessionAndDiscoversPane(t *testing.T) { -- panesOut := []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`) -- outputs := [][]byte{ -- []byte("zellij 0.44.3"), -- nil, -- nil, -- panesOut, -- } -- if runtime.GOOS == "windows" { -- outputs = append(outputs, panesOut) -- } -- outputs = append(outputs, []byte("sess-1 [Created 1s ago] \n")) -- fr := &fakeRunner{outputs: outputs} -- r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh", SocketDir: "/tmp/zj", ConfigDir: "/tmp/cfg"}) -- r.runner = fr -- -- handle, err := r.Create(context.Background(), ports.RuntimeConfig{ -- SessionID: "sess-1", -- WorkspacePath: "/tmp/ws", -- Argv: []string{"echo", "ready"}, -- Env: map[string]string{"AO_SESSION_ID": "sess-1"}, -- }) -- if err != nil { -- t.Fatalf("Create: %v", err) -- } -- if handle != (ports.RuntimeHandle{ID: "sess-1/terminal_3"}) { -- t.Fatalf("handle = %+v, want zellij handle", handle) -- } -- wantCalls := 5 -- if runtime.GOOS == "windows" { -- wantCalls = 6 -- } -- if len(fr.calls) != wantCalls { -- t.Fatalf("calls = %d, want %d", len(fr.calls), wantCalls) -- } -- if got, want := fr.calls[0].args, []string{"--config-dir", "/tmp/cfg", "--version"}; !reflect.DeepEqual(got, want) { -- t.Fatalf("version args = %#v, want %#v", got, want) -- } -- if got, want := fr.calls[1].args, []string{"--config-dir", "/tmp/cfg", "delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { -- t.Fatalf("delete args = %#v, want %#v", got, want) -- } -- if got := fr.calls[2].args[:5]; !reflect.DeepEqual(got, []string{"--config-dir", "/tmp/cfg", "attach", "--create-background", "sess-1"}) { -- t.Fatalf("create args prefix = %#v", got) -- } -- if got := fr.calls[3].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listPanesArgs("sess-1")...)) { -- t.Fatalf("list panes args = %#v", got) -- } -- listSessionsCall := 4 -- if runtime.GOOS == "windows" { -- if got := fr.calls[4].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listPanesArgs("sess-1")...)) { -- t.Fatalf("ready list panes args = %#v", got) -- } -- listSessionsCall = 5 -- } -- if got := fr.calls[listSessionsCall].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listSessionsArgs()...)) { -- t.Fatalf("list sessions args = %#v", got) -- } -- if got, want := fr.calls[0].env, expectedZellijEnv("/tmp/zj"); !reflect.DeepEqual(got, want) { -- t.Fatalf("env = %#v, want %#v", got, want) -- } --} -- --func TestCreateClearsStaleSessionBeforeCreating(t *testing.T) { -- panesOut := []byte(`[{"id":1,"is_plugin":false,"title":"agent"}]`) -- outputs := [][]byte{ -- []byte("zellij 0.44.3"), -- nil, -- nil, -- panesOut, -- } -- if runtime.GOOS == "windows" { -- outputs = append(outputs, panesOut) -- } -- outputs = append(outputs, []byte("sess-1 [Created 1s ago] \n")) -- fr := &fakeRunner{outputs: outputs} -- r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh"}) -- r.runner = fr -- -- if _, err := r.Create(context.Background(), ports.RuntimeConfig{ -- SessionID: "sess-1", -- WorkspacePath: "/tmp/ws", -- Argv: []string{"echo", "ready"}, -- }); err != nil { -- t.Fatalf("Create: %v", err) -- } -- -- wantCalls := 5 -- if runtime.GOOS == "windows" { -- wantCalls = 6 -- } -- if len(fr.calls) != wantCalls { -- t.Fatalf("calls = %d, want %d", len(fr.calls), wantCalls) -- } -- if got, want := fr.calls[1].args, []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { -- t.Fatalf("delete args = %#v, want %#v", got, want) -- } -- if got := fr.calls[2].args[:3]; !reflect.DeepEqual(got, []string{"attach", "--create-background", "sess-1"}) { -- t.Fatalf("create args prefix = %#v", got) -- } --} -- --func TestAttachCommandUsesEmbeddedClientOptions(t *testing.T) { -- r := New(Options{}) -- args, _, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) -- if err != nil { -- t.Fatalf("AttachCommand: %v", err) -- } -- embeddedOptions := []string{ -- "--pane-frames", "false", -- "--mouse-mode", "true", -- "--advanced-mouse-actions", "false", -- "--mouse-hover-effects", "false", -- "--focus-follows-mouse", "false", -- "--mouse-click-through", "false", -- "--support-kitty-keyboard-protocol", "false", -- } -- if runtime.GOOS == "windows" { -- joined := strings.Join(args, " ") -- for _, want := range embeddedOptions { -- if !strings.Contains(joined, want) { -- t.Fatalf("windows attach command missing %q: %#v", want, args) -- } -- } -- return -- } -- want := append(expectedAttachEnvPrefix(), r.binary, "attach", "sess-1", "options") -- want = append(want, embeddedOptions...) -- if !reflect.DeepEqual(args, want) { -- t.Fatalf("AttachCommand = %#v, want %#v", args, want) -- } --} -- --func TestAttachCommandUsesSocketDir(t *testing.T) { -- r := New(Options{SocketDir: "/tmp/zj"}) -- args, _, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) -- if err != nil { -- t.Fatalf("AttachCommand: %v", err) -- } -- if runtime.GOOS == "windows" { -- if got, want := args[0], r.binary; got != want { -- t.Fatalf("attach binary = %q, want %q", got, want) -- } -- return -- } -- if got, want := args[:6], []string{"env", "-u", "NO_COLOR", "TERM=xterm-256color", "COLORTERM=truecolor", "ZELLIJ_SOCKET_DIR=/tmp/zj"}; !reflect.DeepEqual(got, want) { -- t.Fatalf("attach prefix = %#v, want %#v", got, want) -- } -- if got, want := args[6], r.binary; got != want { -- t.Fatalf("attach binary = %q, want %q", got, want) -- } --} -- --func TestFindAgentPaneRetriesTransientErrors(t *testing.T) { -- fr := &fakeRunner{outputs: [][]byte{[]byte("boom"), []byte(`[{"id":0,"is_plugin":false,"title":"agent"}]`)}} -- r := New(Options{Timeout: time.Second}) -- r.runner = fr -- -- got, err := r.findAgentPane(context.Background(), "sess-1") -- if err != nil { -- t.Fatalf("findAgentPane: %v", err) -- } -- if got != "terminal_0" { -- t.Fatalf("findAgentPane = %q, want terminal_0", got) -- } -- if len(fr.calls) != 2 { -- t.Fatalf("calls = %d, want 2", len(fr.calls)) -- } --} -- --func TestParseVersion(t *testing.T) { -- for _, tc := range []struct { -- in string -- want semver -- }{ -- {in: "zellij 0.44.3", want: semver{0, 44, 3}}, -- {in: "zellij v1.2.3\n", want: semver{1, 2, 3}}, -- {in: "zellij 0.44.3-dev", want: semver{0, 44, 3}}, -- } { -- got, err := parseVersion(tc.in) -- if err != nil { -- t.Fatalf("parseVersion(%q): %v", tc.in, err) -- } -- if got != tc.want { -- t.Fatalf("parseVersion(%q) = %v, want %v", tc.in, got, tc.want) -- } -- } -- if _, err := parseVersion("zellij nope"); err == nil { -- t.Fatal("parseVersion invalid: got nil, want error") -- } -- if compareVersion(semver{0, 44, 2}, semver{0, 44, 3}) >= 0 { -- t.Fatal("compareVersion should order 0.44.2 before 0.44.3") -- } -- if got := RequiredVersion(); got != "0.44.3" { -- t.Fatalf("RequiredVersion = %q, want 0.44.3", got) -- } -- if got, err := CheckVersionOutput("zellij 0.44.3"); err != nil || got != "0.44.3" { -- t.Fatalf("CheckVersionOutput supported = %q, %v", got, err) -- } -- if _, err := CheckVersionOutput("zellij 0.44.2"); err == nil { -- t.Fatal("CheckVersionOutput unsupported: got nil error") -- } --} -- --func TestSendMessageChunksAndSendsEnter(t *testing.T) { -- fr := &fakeRunner{} -- r := New(Options{Timeout: time.Second, ChunkSize: 5}) -- r.runner = fr -- -- if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, "hello世界"); err != nil { -- t.Fatalf("SendMessage: %v", err) -- } -- if len(fr.calls) != 4 { -- t.Fatalf("calls = %d, want 4", len(fr.calls)) -- } -- if got, want := fr.calls[0].args, pasteArgs("sess-1", "terminal_0", "hello"); !reflect.DeepEqual(got, want) { -- t.Fatalf("paste 1 args = %#v, want %#v", got, want) -- } -- if got, want := fr.calls[1].args, pasteArgs("sess-1", "terminal_0", "世"); !reflect.DeepEqual(got, want) { -- t.Fatalf("paste 2 args = %#v, want %#v", got, want) -- } -- if got, want := fr.calls[2].args, pasteArgs("sess-1", "terminal_0", "界"); !reflect.DeepEqual(got, want) { -- t.Fatalf("paste 3 args = %#v, want %#v", got, want) -- } -- if got, want := fr.calls[3].args, sendEnterArgs("sess-1", "terminal_0"); !reflect.DeepEqual(got, want) { -- t.Fatalf("enter args = %#v, want %#v", got, want) -- } --} -- --func TestGetOutputTrimsLines(t *testing.T) { -- fr := &fakeRunner{outputs: [][]byte{[]byte("one\ntwo\nthree\n")}} -- r := New(Options{Timeout: time.Second}) -- r.runner = fr -- -- out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, 2) -- if err != nil { -- t.Fatalf("GetOutput: %v", err) -- } -- if out != "two\nthree\n" { -- t.Fatalf("output = %q, want last two lines", out) -- } --} -- --func TestGetOutputTrimsTrailingScreenPaddingBeforeTailing(t *testing.T) { -- fr := &fakeRunner{outputs: [][]byte{[]byte("ready\nprompt> echo hi\nhi\n\n\n\n")}} -- r := New(Options{Timeout: time.Second}) -- r.runner = fr -- -- out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, 2) -- if err != nil { -- t.Fatalf("GetOutput: %v", err) -- } -- if out != "prompt> echo hi\nhi\n" { -- t.Fatalf("output = %q, want last non-padding lines", out) -- } --} -- --func TestIsAliveParsesNoFormattingOutput(t *testing.T) { -- fr := &fakeRunner{outputs: [][]byte{[]byte("sess-1 [Created 1s ago] \nold [Created 2s ago] (EXITED - attach to resurrect)\n")}} -- r := New(Options{Timeout: time.Second}) -- r.runner = fr -- -- alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}) -- if err != nil { -- t.Fatalf("IsAlive: %v", err) -- } -- if !alive { -- t.Fatal("alive = false, want true") -- } -- if sessionListedAlive("sess-1-long [Created 1s ago]", "sess-1") { -- t.Fatal("prefix matched as alive") -- } -- if sessionListedAlive("sess-1 [Created 1s ago] (EXITED - attach to resurrect)", "sess-1") { -- t.Fatal("exited session matched as alive") -- } --} -- --// IsAlive may treat a non-zero list-sessions ONLY as "not alive" when zellij --// says no sessions exist at all. Any other exit failure is a probe error: the --// reaper reports it as a failed probe and the LCM must never read it as death. --func TestIsAliveTreatsNoSessionsExitAsNotAlive(t *testing.T) { -- fr := &fakeRunner{outputs: [][]byte{[]byte("No active zellij sessions found.")}, err: &exec.ExitError{}} -- r := New(Options{Timeout: time.Second}) -- r.runner = fr -- -- alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}) -- if err != nil { -- t.Fatalf("IsAlive: %v", err) -- } -- if alive { -- t.Fatal("alive = true, want false") -- } --} -- --func TestIsAliveReportsOtherExitFailuresAsProbeErrors(t *testing.T) { -- fr := &fakeRunner{outputs: [][]byte{[]byte("thread 'main' panicked")}, err: &exec.ExitError{}} -- r := New(Options{Timeout: time.Second}) -- r.runner = fr -- -- alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}) -- if err == nil { -- t.Fatal("IsAlive: got nil error, want probe failure — a failed probe must not read as dead") -- } -- if alive { -- t.Fatal("alive = true on probe failure") -- } --} -- --func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { -- fr := &fakeRunner{outputs: [][]byte{[]byte("No active zellij sessions found.")}, err: &exec.ExitError{}} -- r := New(Options{Timeout: time.Second}) -- r.runner = fr -- -- if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}); err != nil { -- t.Fatalf("Destroy: %v", err) -- } -- if len(fr.calls) != 1 || fr.calls[0].args[0] != "delete-session" { -- t.Fatalf("calls = %#v, want only delete-session", fr.calls) -- } --} -- --func TestDestroyReportsUnexpectedExitFailures(t *testing.T) { -- fr := &fakeRunner{outputs: [][]byte{[]byte("permission denied")}, err: &exec.ExitError{}} -- r := New(Options{Timeout: time.Second}) -- r.runner = fr -- -- if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}); err == nil { -- t.Fatal("Destroy: got nil, want unexpected delete-session failure") -- } --} -- --// Destroy must delete the session's serialized state, not merely kill it: a --// killed-but-cached session is resurrected (agent re-run included) by any later --// `zellij attach`, bringing a terminated session's runtime back to life. --func TestDestroyForceDeletesSerializedSession(t *testing.T) { -- fr := &fakeRunner{} -- r := New(Options{Timeout: time.Second}) -- r.runner = fr -- -- if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}); err != nil { -- t.Fatalf("Destroy: %v", err) -- } -- if len(fr.calls) != 1 { -- t.Fatalf("calls = %d, want 1", len(fr.calls)) -- } -- if got, want := fr.calls[0].args, []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { -- t.Fatalf("destroy args = %#v, want %#v", got, want) -- } --} -- --func TestGetOutputValidatesLines(t *testing.T) { -- r := New(Options{Timeout: time.Second}) -- _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, 0) -- if err == nil { -- t.Fatal("GetOutput lines=0: got nil, want error") -- } --} -- --type fakeRunner struct { -- calls []runnerCall -- outputs [][]byte -- err error --} -- --type runnerCall struct { -- env []string -- name string -- args []string --} -- --func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { -- f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) -- var out []byte -- if len(f.outputs) > 0 { -- out = f.outputs[0] -- f.outputs = f.outputs[1:] -- } -- if f.err != nil { -- return out, f.err -- } -- return out, nil --} -- --func (f *fakeRunner) Start(env []string, name string, args ...string) error { -- f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) -- if len(f.outputs) > 0 { -- f.outputs = f.outputs[1:] -- } -- return f.err --} -- --func TestCommandErrorUnwraps(t *testing.T) { -- base := errors.New("base") -- err := commandError{err: base, output: "details"} -- if !errors.Is(err, base) { -- t.Fatal("commandError should unwrap base error") -- } -- if !strings.Contains(err.Error(), "details") { -- t.Fatalf("error = %q, want output details", err.Error()) -- } --} -diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go -index a52fa52..96492e3 100644 ---- a/backend/internal/cli/doctor.go -+++ b/backend/internal/cli/doctor.go -@@ -12,21 +12,20 @@ import ( - "path/filepath" - "regexp" - "runtime" - "strconv" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" -- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - ) - - type doctorLevel string - - const ( - doctorPass doctorLevel = "PASS" - doctorWarn doctorLevel = "WARN" - doctorFail doctorLevel = "FAIL" - ) -@@ -284,25 +283,30 @@ func (c *commandContext) checkGit(ctx context.Context) doctorCheck { - cmp, err := compareDottedVersion(version, minGitVersion) - if err != nil { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version unknown: %s)", path, firstOutputLine(out))} - } - if cmp < 0 { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; AO expects >= %s for worktrees)", path, version, minGitVersion)} - } - return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; supports worktrees)", path, version)} - } - --// checkTerminalRuntime checks for the runtime multiplexer used on this platform: --// tmux on Darwin/Linux, zellij on Windows. -+// checkTerminalRuntime checks the runtime multiplexer used on this platform: -+// tmux on Darwin/Linux, ConPTY (built-in) on Windows. - func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { - if runtime.GOOS == "windows" { -- return c.checkZellij(ctx) -+ return doctorCheck{ -+ Level: doctorPass, -+ Section: doctorSectionTools, -+ Name: "conpty", -+ Message: "ConPTY (built-in): no external terminal multiplexer required on Windows", -+ } - } - return c.checkTmux(ctx) - } - - func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { - path, err := c.deps.LookPath("tmux") - if err != nil || path == "" { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"} - } - reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) -@@ -311,38 +315,20 @@ func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { - if err != nil { - return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s: %v", path, err)} - } - version := firstOutputLine(out) - if version == "" { - version = "version unknown" - } - return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s)", path, version)} - } - --func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { -- path, err := c.deps.LookPath("zellij") -- if err != nil || path == "" { -- return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "zellij", Message: "not found in PATH"} -- } -- reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) -- defer cancel() -- out, err := c.deps.CommandOutput(reqCtx, path, "--version") -- if err != nil { -- return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} -- } -- version, err := zellij.CheckVersionOutput(string(out)) -- if err != nil { -- return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} -- } -- return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s (version %s; require >= %s)", path, version, zellij.RequiredVersion())} --} -- - // checkHooksLog surfaces recent agent hook delivery failures. `ao hooks` - // callbacks deliberately swallow errors (a hook must never break the user's - // agent), so $AO_DATA_DIR/hooks.log is the only place a dead activity feed - // becomes visible. Lines start with an RFC3339 timestamp (see appendHooksLog). - func checkHooksLog(dataDir string, now time.Time) doctorCheck { - const name = "hooks-log" - path := filepath.Join(dataDir, hooksLogName) - data, err := os.ReadFile(path) //nolint:gosec // path rooted in AO's own data dir - if errors.Is(err, fs.ErrNotExist) { - return doctorCheck{Level: doctorPass, Section: doctorSectionCore, Name: name, Message: "no hook delivery failures recorded"} -diff --git a/backend/internal/cli/ptyhost.go b/backend/internal/cli/ptyhost.go -new file mode 100644 -index 0000000..d926597 ---- /dev/null -+++ b/backend/internal/cli/ptyhost.go -@@ -0,0 +1,29 @@ -+package cli -+ -+import ( -+ "os" -+ -+ "github.com/spf13/cobra" -+ -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty" -+) -+ -+// newPtyHostCommand registers the "ao pty-host" hidden subcommand that the -+// conpty runtime spawns on Windows to host a ConPTY session over loopback TCP. -+// DisableFlagParsing ensures agent shell args with leading dashes are not -+// consumed by cobra before being passed to RunHost. -+func newPtyHostCommand() *cobra.Command { -+ return &cobra.Command{ -+ Use: "pty-host", -+ Short: "Run a ConPTY pty-host process (internal)", -+ Hidden: true, -+ DisableFlagParsing: true, -+ RunE: func(_ *cobra.Command, args []string) error { -+ code := conpty.RunHost(args, os.Stdout) -+ if code != 0 { -+ os.Exit(code) -+ } -+ return nil -+ }, -+ } -+} -diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go -index 3bb1fce..da2db9c 100644 ---- a/backend/internal/cli/root.go -+++ b/backend/internal/cli/root.go -@@ -181,20 +181,21 @@ func NewRootCommand(deps Deps) *cobra.Command { - root.AddCommand(newDaemonCommand()) - root.AddCommand(newStartCommand(ctx)) - root.AddCommand(newStopCommand(ctx)) - root.AddCommand(newStatusCommand(ctx)) - root.AddCommand(newDoctorCommand(ctx)) - root.AddCommand(newSpawnCommand(ctx)) - root.AddCommand(newSendCommand(ctx)) - root.AddCommand(newPreviewCommand(ctx)) - root.AddCommand(newHooksCommand(ctx)) - root.AddCommand(newLaunchCommand(ctx)) -+ root.AddCommand(newPtyHostCommand()) - root.AddCommand(newImportCommand(ctx)) - root.AddCommand(newProjectCommand(ctx)) - root.AddCommand(newSessionCommand(ctx)) - root.AddCommand(newOrchestratorCommand(ctx)) - root.AddCommand(newReviewCommand(ctx)) - root.AddCommand(newCompletionCommand()) - root.AddCommand(newVersionCommand()) - - return root - } -diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go -index ae7bac4..d19d540 100644 ---- a/backend/internal/cli/spawn.go -+++ b/backend/internal/cli/spawn.go -@@ -3,21 +3,20 @@ package cli - import ( - "context" - "fmt" - "net/url" - "runtime" - - "github.com/spf13/cobra" - "github.com/spf13/pflag" - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" -- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - ) - - type spawnOptions struct { - project string - harness string - branch string - prompt string - issue string - claimPR string - noTakeover bool -@@ -94,29 +93,26 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { - out := cmd.OutOrStdout() - claimLabel := "" - if claimed != "" { - claimLabel = fmt.Sprintf(" (claimed %s)", claimed) - } - if _, err := fmt.Fprintf(out, "spawned session %s (%s)%s\n", res.Session.ID, res.Session.Status, claimLabel); err != nil { - return err - } - // Print a copy-pasteable attach hint for the selected runtime. - // On Darwin/Linux: tmux attach-session using the sanitised session name. -- // On Windows: zellij attach with the short socket dir prefix. -+ // On Windows: ConPTY has no user-facing attach CLI; use the AO dashboard. - var attach string - if runtime.GOOS != "windows" { - attach = fmt.Sprintf("tmux attach -t %s", tmux.SessionName(res.Session.ID)) - } else { -- attach = fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) -- if dir := zellij.DefaultSocketDir(); dir != "" { -- attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) -- } -+ attach = "Attach from the AO dashboard (ConPTY sessions have no CLI attach command)" - } - _, err := fmt.Fprintf(out, "attach with: %s\n", attach) - return err - }, - } - f := cmd.Flags() - // --agent is an alias for --harness so the more intuitive `ao spawn --agent - // droid` works identically; both resolve to the same harness flag. - f.SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName { - if name == "agent" { -diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go -index 5d21160..add2473 100644 ---- a/backend/internal/daemon/lifecycle_wiring.go -+++ b/backend/internal/daemon/lifecycle_wiring.go -@@ -120,21 +120,21 @@ func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlit - Sessions: store, - PRs: store, - Projects: store, - Launcher: reviewcore.NewLauncher(reviewers, runtime), - }) - reviewSvc := reviewsvc.New(reviewEngine, store, reviewsvc.WithLifecycleReducer(lcm)) - return sessionSvc, reviewSvc, nil - } - - // runtimeMessageSender is the narrow part of the concrete runtime needed by --// ao send. zellij.Runtime already implements this via SendMessage. -+// ao send. Both tmux.Runtime and conpty.Runtime implement this via SendMessage. - type runtimeMessageSender interface { - SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error - } - - // runtimeMessenger sends the user's message directly to the session's live - // runtime pane. The HTTP controller has already validated and sanitized the - // message body; this adapter only resolves the stored runtime handle. - type runtimeMessenger struct { - store *sqlite.Store - runtime runtimeMessageSender -diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go -index 21bd248..eda8ac1 100644 ---- a/backend/internal/daemon/wiring_test.go -+++ b/backend/internal/daemon/wiring_test.go -@@ -4,21 +4,21 @@ import ( - "context" - "errors" - "io" - "log/slog" - "sync" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" -- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" - telemetryadapter "github.com/aoagents/agent-orchestrator/backend/internal/adapters/telemetry" - "github.com/aoagents/agent-orchestrator/backend/internal/cdc" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" - ) - -@@ -314,21 +314,21 @@ func TestWiring_StartLifecycleThreadsMessengerIntoLCM(t *testing.T) { - rec, err := store.CreateSession(ctx, domain.SessionRecord{ - ProjectID: "p", Kind: domain.KindWorker, - Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: time.Now()}, - }) - if err != nil { - t.Fatal(err) - } - - log := slog.New(slog.NewTextHandler(io.Discard, nil)) - messenger := &captureMessenger{} -- stack := startLifecycle(ctx, store, zellij.New(zellij.Options{}), messenger, nil, nil, log) -+ stack := startLifecycle(ctx, store, tmux.New(tmux.Options{}), messenger, nil, nil, log) - t.Cleanup(stack.Stop) - t.Cleanup(cancel) - - obs := ports.SCMObservation{ - Fetched: true, - PR: ports.SCMPRObservation{URL: "https://github.com/o/r/pull/1", Number: 1, HeadSHA: "c1"}, - CI: ports.SCMCIObservation{ - Summary: string(domain.CIFailing), - HeadSHA: "c1", - FailedChecks: []ports.SCMCheckObservation{{Name: "build", Status: string(domain.PRCheckFailed), LogTail: "boom"}}, -@@ -371,29 +371,10 @@ func TestProjectRepoResolver_ResolvesRegisteredProject(t *testing.T) { - _, err = r.RepoPath("nope") - if err == nil { - t.Fatal("expected an error for an unregistered project") - } - // Guard the sentinel wrapping so the HTTP 400 mapping can't silently regress. - if !errors.Is(err, sessionmanager.ErrProjectNotResolvable) { - t.Fatalf("unregistered-project error should wrap ErrProjectNotResolvable, got %v", err) - } - } - --// TestDaemonZellijSocketDir_LeavesBudgetForSessionNames guards the fix for the --// zellij "session name must be less than 0 characters" spawn failure: the --// daemon's socket dir must be short enough that a max-length (48-char) session --// name still fits the ~103-byte unix-domain-socket-path budget. zellij's long --// $TMPDIR default (the bug) would fail this. --func TestDaemonZellijSocketDir_LeavesBudgetForSessionNames(t *testing.T) { -- dir := zellij.DefaultSocketDir() -- if dir == "" { -- t.Skip("zellij not used on this platform") -- } -- const ( -- unixSocketPathMax = 103 // sun_path budget zellij enforces on macOS -- zellijOverhead = 24 // zellij's version subdir + separators (generous) -- maxSessionName = 48 // zellijSessionName's cap -- ) -- if budget := unixSocketPathMax - len(dir) - zellijOverhead; budget < maxSessionName { -- t.Fatalf("zellij socket dir %q too long: %d bytes left for the session name, need >= %d", dir, budget, maxSessionName) -- } --} -diff --git a/backend/internal/terminal/attachment_integration_test.go b/backend/internal/terminal/attachment_integration_test.go -index 452362f..940a919 100644 ---- a/backend/internal/terminal/attachment_integration_test.go -+++ b/backend/internal/terminal/attachment_integration_test.go -@@ -1,97 +1,76 @@ - //go:build !windows - - package terminal - - import ( - "context" - "os" - "os/exec" -- "path/filepath" - "strconv" - "strings" - "testing" - "time" - -- "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" -+ "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - ) - --// TestAttachmentStreamsRealZellijPane attaches a real PTY to a real Zellij --// session and asserts output streams back, then that killing the pane stops the --// attachment without a re-attach storm. Skipped when Zellij is unavailable. --func TestAttachmentStreamsRealZellijPane(t *testing.T) { -- zellijBin, err := exec.LookPath("zellij") -- if err != nil { -- t.Skip("zellij unavailable") -+// TestAttachmentStreamsRealTmuxPane attaches a real PTY to a real tmux session -+// and asserts output streams back, then that killing the session stops the -+// attachment without a re-attach storm. Skipped when tmux is unavailable. -+func TestAttachmentStreamsRealTmuxPane(t *testing.T) { -+ if _, err := exec.LookPath("tmux"); err != nil { -+ t.Skip("tmux unavailable") - } - - name := "ao-term-it-" + strconv.Itoa(os.Getpid()) -- socketDir := filepath.Join("/tmp", name+"-socket") -- if err := os.MkdirAll(socketDir, 0o755); err != nil { -- t.Fatalf("mkdir socket dir: %v", err) -- } -- rt := zellij.New(zellij.Options{Binary: zellijBin, SocketDir: socketDir, ConfigDir: t.TempDir(), Timeout: 5 * time.Second}) -+ rt := tmux.New(tmux.Options{Timeout: 10 * time.Second}) - handle, err := rt.Create(context.Background(), ports.RuntimeConfig{ - SessionID: domain.SessionID(name), - WorkspacePath: t.TempDir(), - Argv: []string{"sh", "-lc", "printf AO_READY\\n; exec sh -i"}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) }) - - var got safeBytes - a := newAttachment(name, handle, rt, nil, got.add, nil, testLogger()) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go a.run(ctx) - - // Type a unique marker and expect it echoed back through the PTY. - eventually(t, 3*time.Second, func() bool { return a.write([]byte("echo AO_MARKER_42\n")) == nil }) - eventually(t, 5*time.Second, func() bool { return strings.Contains(got.string(), "AO_MARKER_42") }) - -- // A fresh attach must carry zellij's alt-screen init handshake. Mouse -- // reporting is deliberately disabled for AO's embedded client, so this test -- // should not require SGR mouse mode. -- eventually(t, 5*time.Second, func() bool { -- out := got.string() -- return strings.Contains(out, "\x1b[?1049h") -- }) -- - // Kill the session: the attachment must observe it as gone and not re-attach. - if err := rt.Destroy(context.Background(), handle); err != nil { - t.Fatalf("Destroy: %v", err) - } - eventually(t, 5*time.Second, func() bool { return a.isExited() }) - } - - // TestAttachmentReattachAdoptsNewSize is the end-to-end regression for the - // stale-size desync: client A holds the session at one grid, detaches, and - // client B immediately attaches at a different grid (the frontend's --// remount/reconnect flow). B's zellij client must adopt B's size — the inner --// pane's tty must report it — not stay laid out for A's. This is where the --// spawn-at-size + explicit-WINCH + SIGTERM-detach fixes meet a real zellij. -+// remount/reconnect flow). B's tmux client must adopt B's size, not A's. - func TestAttachmentReattachAdoptsNewSize(t *testing.T) { -- zellijBin, err := exec.LookPath("zellij") -- if err != nil { -- t.Skip("zellij unavailable") -+ if _, err := exec.LookPath("tmux"); err != nil { -+ t.Skip("tmux unavailable") - } - - name := "ao-term-size-it-" + strconv.Itoa(os.Getpid()) -- socketDir := filepath.Join("/tmp", name+"-socket") -- if err := os.MkdirAll(socketDir, 0o755); err != nil { -- t.Fatalf("mkdir socket dir: %v", err) -- } -- rt := zellij.New(zellij.Options{Binary: zellijBin, SocketDir: socketDir, ConfigDir: t.TempDir(), Timeout: 5 * time.Second}) -+ rt := tmux.New(tmux.Options{Timeout: 10 * time.Second}) - handle, err := rt.Create(context.Background(), ports.RuntimeConfig{ - SessionID: domain.SessionID(name), - WorkspacePath: t.TempDir(), - Argv: []string{"sh", "-lc", "printf AO_READY\\n; exec sh -i"}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) }) - -@@ -104,47 +83,45 @@ func TestAttachmentReattachAdoptsNewSize(t *testing.T) { - } - ctx, cancel := context.WithCancel(context.Background()) - go a.run(ctx) - return a, &got, opened, cancel - } - - // Client A at 115x37: wait for the pane shell, then detach. - a, _, openedA, cancelA := attachAt(37, 115) - select { - case <-openedA: -- case <-time.After(5 * time.Second): -+ case <-time.After(10 * time.Second): - t.Fatal("client A did not attach") - } - a.close() - cancelA() - -- // Client B re-attaches immediately at 148x40 — no settle gap, same as the -- // frontend reconnecting. The inner pane must see B's grid (zellij chrome -- // shaves a couple rows/cols, so assert the reported cols land near 148 and -- // far from 115). -+ // Client B re-attaches immediately at 148x40. The inner pane must see B's -+ // grid (tmux may shave a row/col; assert cols land near 148 and far from 115). - b, gotB, openedB, cancelB := attachAt(40, 148) - defer cancelB() - defer b.close() - select { - case <-openedB: -- case <-time.After(5 * time.Second): -+ case <-time.After(10 * time.Second): - t.Fatal("client B did not attach") - } - - eventually(t, 5*time.Second, func() bool { return b.write([]byte("echo SIZE:$(stty size)\n")) == nil }) - eventually(t, 10*time.Second, func() bool { - out := gotB.string() - i := strings.LastIndex(out, "SIZE:") - if i < 0 { - return false - } - fields := strings.Fields(strings.TrimPrefix(out[i:], "SIZE:")) - if len(fields) < 2 { - return false - } - cols, err := strconv.Atoi(strings.TrimFunc(fields[1], func(r rune) bool { return r < '0' || r > '9' })) - if err != nil { - return false - } -- return cols > 130 // B's 148 minus zellij chrome; a stale A-layout reports ≤115 -+ return cols > 130 // B's 148 minus any tmux chrome; a stale A-layout reports <=115 - }) - } - ---- Changes --- - -backend/internal/adapters/runtime/runtimeselect/runtimeselect.go - @@ -1,47 +1,37 @@ - -// tmux on Darwin/Linux, zellij on Windows (interim; ConPTY adapter is a later phase). - +// tmux on Darwin/Linux, conpty (ConPTY) on Windows. - package runtimeselect - - import ( - "context" - "log/slog" - - "os" - "runtime" - - + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" - - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - ) - - -// Runtime is the union interface that both tmux and zellij satisfy. - +// Runtime is the union interface that both tmux and conpty satisfy. - // It extends ports.Runtime (Create/Destroy/IsAlive) with the additional methods - // the daemon wires directly, including ports.Attacher (Attach) so the terminal - // layer can open a Stream against the selected runtime. - type Runtime interface { - ports.Runtime // Create, Destroy, IsAlive - ports.Attacher - SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error - GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) - } - - // Compile-time assertions: both adapters must implement the union interface. - var _ Runtime = (*tmux.Runtime)(nil) - -var _ Runtime = (*zellij.Runtime)(nil) - +var _ Runtime = (*conpty.Runtime)(nil) - - -// New returns the per-platform runtime: tmux on Darwin/Linux, zellij on Windows. - -// log is used only for the Windows zellij socket-dir warning; nil is safe. - -func New(log *slog.Logger) Runtime { - +// New returns the per-platform runtime: tmux on Darwin/Linux, conpty on Windows. - +// log is accepted for signature stability with callers but is currently unused. - +func New(_ *slog.Logger) Runtime { - if runtime.GOOS != "windows" { - return tmux.New(tmux.Options{}) - } - - // ponytail: mirrors daemon.go's zellij socket-dir setup; Windows ConPTY replaces this in a later phase. - - dir := zellij.DefaultSocketDir() - - if dir != "" { - - if err := os.MkdirAll(dir, 0o700); err != nil { - - if log != nil { - - log.Warn("could not create zellij socket dir; spawns may fail", "dir", dir, "error", err) - - } - - } - - } - - return zellij.New(zellij.Options{SocketDir: dir}) - + return conpty.New(conpty.Options{}) - } - +8 -18 - -backend/internal/adapters/runtime/zellij/commands.go - @@ -1,405 +0,0 @@ - -package zellij - - - -import ( - - "encoding/base64" - - "encoding/binary" - - "fmt" - - "runtime" - - "sort" - - "strconv" - - "strings" - - "unicode/utf16" - - - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - -) - - - -const ( - - agentPaneName = "agent" - - defaultChunkBytes = 16 * 1024 - -) - - - -func versionArgs() []string { - - return []string{"--version"} - -} - - - -func createSessionArgs(id, layoutPath string) []string { - - clientOptions := embeddedClientOptions() - - args := make([]string, 0, 6+len(clientOptions)+6) - - args = append(args, - - "attach", "--create-background", id, - - "options", - - "--default-layout", layoutPath, - - ) - - args = append(args, clientOptions...) - - args = append(args, - - "--session-serialization", "false", - - "--show-startup-tips", "false", - - "--show-release-notes", "false", - - ) - - return args - -} - - - -func listPanesArgs(id string) []string { - - return []string{"--session", id, "action", "list-panes", "--all", "--json"} - -} - - - -func pasteArgs(id, paneID, chunk string) []string { - - if runtime.GOOS == "windows" { - - return []string{"--session", id, "action", "write-chars", "--pane-id", paneID, chunk} - - } - - return []string{"--session", id, "action", "paste", "--pane-id", paneID, chunk} - -} - - - -func sendEnterArgs(id, paneID string) []string { - - return []string{"--session", id, "action", "send-keys", "--pane-id", paneID, "Enter"} - -} - - - -func dumpScreenArgs(id, paneID string) []string { - - return []string{"--session", id, "action", "dump-screen", "--pane-id", paneID, "--full"} - -} - - - -func listSessionsArgs() []string { - - return []string{"list-sessions", "--no-formatting"} - -} - - - -// deleteSessionArgs builds the teardown command. `delete-session --force` - -// kills a running session AND removes its serialized resurrection state in one - -// step. Plain `kill-session` is not enough: zellij can keep the session in its - -// global resurrection cache as "(EXITED - attach to resurrect)", and any later - -// `zellij attach ` (e.g. the terminal mux re-opening a pane) would resurrect - -// it — re-running the agent command for a session the daemon already destroyed. - -func deleteSessionArgs(id string) []string { - - return []string{"delete-session", "--force", id} - -} - - - -func attachArgs(id string) []string { - - clientOptions := embeddedClientOptions() - - args := make([]string, 0, 3+len(clientOptions)) - - args = append(args, - - "attach", id, - - "options", - - ) - - args = append(args, clientOptions...) - - return args - -} - - - -func embeddedClientOptions() []string { - - return []string{ - - "--pane-frames", "false", - - // The dashboard terminal disables xterm local scrollback and lets the - - // embedded zellij client own scrollback. Keep mouse mode on so wheel - - // reports reach zellij, while leaving richer pointer behaviors off. - - "--mouse-mode", "true", - - "--advanced-mouse-actions", "false", - - "--mouse-hover-effects", "false", - - "--focus-follows-mouse", "false", - - "--mouse-click-through", "false", - - "--support-kitty-keyboard-protocol", "false", - - } - -} - - - ... (305 lines truncated) - +0 -405 - -backend/internal/adapters/runtime/zellij/process_other.go - @@ -1,18 +0,0 @@ - -//go:build !windows - - - -package zellij - - - -import ( - - "errors" - - "os/exec" - -) - - - -// hideWindow is a no-op off Windows: only Windows pops a console window. - -func hideWindow(*exec.Cmd) {} - - - -// startBackgroundProcess is a stub: the fire-and-forget path is only used by - -// the Windows zellij codepath. Non-Windows builds create sessions - -// synchronously via runner.Run. - -func startBackgroundProcess(env []string, name string, args ...string) error { - - return errors.New("zellij runtime: background spawn is windows-only") - -} - +0 -18 - -backend/internal/adapters/runtime/zellij/process_windows.go - @@ -1,79 +0,0 @@ - -//go:build windows - - - -package zellij - - - -import ( - - "os/exec" - - "strings" - - "syscall" - - - - "golang.org/x/sys/windows" - -) - - - -// hideWindow suppresses the console window for a console-subsystem child so - -// frequent zellij calls (e.g. the reaper's list-sessions) don't flash on Windows. - -func hideWindow(cmd *exec.Cmd) { - - cmd.SysProcAttr = &syscall.SysProcAttr{ - - HideWindow: true, - - CreationFlags: windows.CREATE_NO_WINDOW, - - } - -} - - - -func startBackgroundProcess(env []string, name string, args ...string) error { - - script := "Start-Process -FilePath " + psQuote(name) + " -ArgumentList " + psQuote(windowsCommandLine(args)) + " -WindowStyle Hidden" - - cmd := exec.Command("powershell.exe", "-NoLogo", "-NoProfile", "-EncodedCommand", powerShellEncodedCommand(script)) - - cmd.Env = env - - cmd.SysProcAttr = &syscall.SysProcAttr{ - - // CREATE_NO_WINDOW, not CREATE_NEW_CONSOLE: the latter creates a console - - // then hides it, which flashed a window. - - CreationFlags: windows.CREATE_NO_WINDOW, - - HideWindow: true, - - } - - if err := cmd.Start(); err != nil { - - return err - - } - - go func() { _ = cmd.Wait() }() - - return nil - -} - - - -func windowsCommandLine(args []string) string { - - quoted := make([]string, len(args)) - - for i, arg := range args { - - quoted[i] = windowsQuoteArg(arg) - - } - - return strings.Join(quoted, " ") - -} - - - -func windowsQuoteArg(arg string) string { - - if arg == "" { - - return `""` - - } - - if !strings.ContainsAny(arg, " \t\"") { - - return arg - - } - - - - var b strings.Builder - - b.WriteByte('"') - - backslashes := 0 - - for _, r := range arg { - - switch r { - - case '\\': - - backslashes++ - - case '"': - - b.WriteString(strings.Repeat(`\`, backslashes*2+1)) - - b.WriteRune(r) - - backslashes = 0 - - default: - - if backslashes > 0 { - - b.WriteString(strings.Repeat(`\`, backslashes)) - - backslashes = 0 - - } - - b.WriteRune(r) - - } - - } - - if backslashes > 0 { - - b.WriteString(strings.Repeat(`\`, backslashes*2)) - - } - - b.WriteByte('"') - - return b.String() - -} - +0 -79 - -backend/internal/adapters/runtime/zellij/zellij.go - @@ -1,964 +0,0 @@ - -// 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/adapters/runtime/ptyexec" - - "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) - -var _ ports.Attacher = (*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() - -} - ... (864 lines truncated) - +0 -964 - -backend/internal/adapters/runtime/zellij/zellij_integration_test.go - @@ -1,165 +0,0 @@ - -package zellij - - - -import ( - - "context" - - "os" - - "os/exec" - - "path/filepath" - - "runtime" - - "strings" - - "testing" - - "time" - - - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - -) - - - -func TestRuntimeIntegration(t *testing.T) { - - if _, err := exec.LookPath("zellij"); err != nil { - - t.Skip("zellij unavailable") - - } - - - - ctx := context.Background() - - id := "ao_itest_zj" - - socketDir := tempSocketDir(t, "ao-zj-itest-") - - configDir := t.TempDir() - - opts := Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: configDir} - - if runtime.GOOS == "windows" { - - opts.Timeout = 30 * time.Second - - opts.LauncherBinary = buildAOForIntegration(t) - - opts.Shell = "cmd.exe" - - } - - r := New(opts) - - _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) - - argv := []string{"sh", "-lc", "printf ready-$AO_SESSION_ID\\n; exec sh -i"} - - sendCommand := "echo hello-from-zellij" - - if runtime.GOOS == "windows" { - - argv = []string{"cmd.exe", "/D", "/Q", "/K", "echo ready-%AO_SESSION_ID%"} - - } - - - - h, err := r.Create(ctx, ports.RuntimeConfig{ - - SessionID: "ao_itest_zj", - - WorkspacePath: t.TempDir(), - - Argv: argv, - - Env: map[string]string{"AO_SESSION_ID": id}, - - }) - - if err != nil { - - t.Fatalf("Create: %v", err) - - } - - defer r.Destroy(ctx, h) - - - - alive, err := r.IsAlive(ctx, h) - - if err != nil { - - t.Fatalf("IsAlive: %v", err) - - } - - if !alive { - - t.Fatal("alive = false, want true") - - } - - prefixAlive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: "ao_itest"}) - - if err != nil { - - t.Fatalf("IsAlive prefix: %v", err) - - } - - if prefixAlive { - - t.Fatal("prefix handle reported alive; zellij session matching is not exact") - - } - - - - out := waitForRuntimeOutput(t, r, h, "ready-") - - if !strings.Contains(out, "ready-") { - - t.Fatalf("output = %q, want ready output", out) - - } - - if err := r.SendMessage(ctx, h, sendCommand); err != nil { - - t.Fatalf("SendMessage: %v", err) - - } - - out = waitForRuntimeOutput(t, r, h, "hello-from-zellij") - - if !strings.Contains(out, "hello-from-zellij") { - - t.Fatalf("output = %q, want sent command output", out) - - } - - - - if err := r.Destroy(ctx, h); err != nil { - - t.Fatalf("Destroy: %v", err) - - } - - alive, err = r.IsAlive(ctx, h) - - if err != nil { - - t.Fatalf("IsAlive after destroy: %v", err) - - } - - if alive { - - t.Fatal("alive after destroy = true, want false") - - } - -} - - - -func waitForRuntimeOutput(t *testing.T, r *Runtime, h ports.RuntimeHandle, want string) string { - - t.Helper() - - deadline := time.Now().Add(3 * time.Second) - - var out string - - for time.Now().Before(deadline) { - - var err error - - out, err = r.GetOutput(context.Background(), h, 30) - - if err != nil { - - t.Fatalf("GetOutput: %v", err) - - } - - if strings.Contains(out, want) { - - return out - ... (65 lines truncated) - +0 -165 - -backend/internal/adapters/runtime/zellij/zellij_test.go - @@ -1,734 +0,0 @@ - -package zellij - - - -import ( - - "context" - - "errors" - - "os/exec" - - "reflect" - - "runtime" - - "strings" - - "testing" - - "time" - - - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - -) - - - -func TestNewDefaultsToPortableShell(t *testing.T) { - - t.Setenv("SHELL", "") - - r := New(Options{}) - - want := "/bin/sh" - - if runtime.GOOS == "windows" { - - want = "powershell.exe" - - } - - if got := r.shell; got != want { - - t.Fatalf("default shell = %q, want %q", got, want) - - } - -... (more changes truncated) - +0 -26 -[full diff: rtk git diff --no-compact] From 08e21dedd5768f6b0a9dfeef999880178d8a5adb Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 07:19:00 +0530 Subject: [PATCH 40/60] docs(spec): graceful restore + post-failure orchestrator recreate Fix the opaque 500 when restoring an un-resumable session (typed 409 SESSION_NOT_RESUMABLE), and add a post-failure popup that offers to recreate a fresh orchestrator on the same branch (cleaning the worktree, preserving committed history). Orchestrators only; recreate fires only after a restore attempt confirms the session cannot be resumed. Co-Authored-By: Claude Opus 4.8 --- ...24-restore-recreate-orchestrator-design.md | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-restore-recreate-orchestrator-design.md diff --git a/docs/superpowers/specs/2026-06-24-restore-recreate-orchestrator-design.md b/docs/superpowers/specs/2026-06-24-restore-recreate-orchestrator-design.md new file mode 100644 index 00000000..7998ce0a --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-restore-recreate-orchestrator-design.md @@ -0,0 +1,212 @@ +# Design: Graceful Restore + Post-Failure Orchestrator Recreate + +## Problem + +Clicking "Restore session" on a terminated session that has no resumable state +returns an opaque **HTTP 500** and the UI shows "Internal server error". Root +cause, traced through the running build: + +- `manager.Restore` (`backend/internal/session_manager/manager.go:479-480`) + returns a **plain** error when a session has neither an agent session id nor a + prompt: + ```go + if meta.AgentSessionID == "" && meta.Prompt == "" { + return ..., fmt.Errorf("restore %s: nothing to resume from", id) + } + ``` +- `toAPIError` (`backend/internal/service/session/service.go:444`) maps known + sentinels (`ErrNotRestorable`, `ErrIncompleteHandle`, ...) to clean 4xx codes, + but an unrecognized error "passes through and surfaces as a 500" (its own + comment). "nothing to resume from" is not a sentinel, so the user gets a 500. + +Observed on `ao-agents-8`: a terminated **orchestrator** with empty +`agent_session_id` and empty `prompt` (a stale pre-lifecycle-feature orphan). +The branch `ao/ao-agents-orchestrator` still exists with its committed history; +only the resumable agent state is gone. + +This is a **pre-existing** bug (the single-session restore endpoint predates the +session-lifecycle feature). It is now visible because terminating such a session +makes the UI offer its Restore button. + +## Goals + +1. A restore that cannot succeed returns a clear, typed client error, never a 500. +2. When restore is confirmed impossible for an **orchestrator**, the user is + offered, via a **popup that appears only after clicking Restore**, the option + to create a fresh orchestrator on the same branch (preserving committed + history), cleaning the old worktree. +3. Restore is offered/attempted normally for sessions that CAN be restored; the + recreate path never fires unless a restore attempt was made and the backend + confirmed it is not resumable. No orchestrator spam when restore works. + +## Non-Goals + +- Workers get the clear error + popup explanation, but **no** recreate action + (scope decision: orchestrators only). +- No change to how restorable sessions resume (the existing resume path stays + behaviorally unchanged). +- No upfront `restorable` flag on the session DTO: the flow is driven by the + restore attempt's response, so a precomputed flag is unnecessary (YAGNI). + +## Core reframe + +Two distinct operations on a terminated session share worktree machinery but +differ at launch: + +- **Restore** = re-attach a worktree on the existing branch + **resume** the + agent (requires `agent_session_id` or `prompt`). +- **Recreate orchestrator** = re-attach a worktree on the existing branch + + launch a **fresh** orchestrator agent (no resume state needed). + +`worktree add` has two arg builders in +`backend/internal/adapters/workspace/gitworktree/commands.go`: +`worktreeAddBranchArgs` (existing branch, no `-b`, used by `Restore`) and +`worktreeAddNewBranchArgs` (`-b`, new branch, used by `Create`/Spawn). Recreate +must REUSE the existing branch, so it goes through the existing-branch attach +(the `Restore` path), NOT Spawn's `-b` path. + +## Design + +### Backend + +#### 1. Typed error for un-resumable restore (fixes the 500) +- Add sentinel in `session_manager` (next to the existing sentinels near + `manager.go:25`): + ```go + ErrNotResumable = errors.New("session: nothing to resume from") + ``` +- Use it at `manager.go:480`: + ```go + return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, ErrNotResumable) + ``` +- Map it in `toAPIError` (`service/session/service.go`), alongside the sibling + cases, as a **409**: + ```go + case errors.Is(err, sessionmanager.ErrNotResumable): + return apierr.Conflict("SESSION_NOT_RESUMABLE", + "This session has no saved agent session or prompt to resume from", nil) + ``` + +#### 2. Recreate endpoint (orchestrator-only) +- Route: `POST /api/v1/sessions/{sessionId}/recreate`, registered next to the + other session routes in `httpd/controllers/sessions.go`, handled by a new + `SessionsController.recreate` that calls `Svc.RecreateOrchestrator`. +- Service method `RecreateOrchestrator(ctx, id) (domain.Session, error)` wraps + the manager method and runs its error through `toAPIError`. +- Manager method `RecreateOrchestrator(ctx, id domain.SessionID) + (domain.SessionRecord, error)`: + 1. `GetSession`; 404 (`ErrNotFound`) if absent. + 2. Validate `rec.Kind == domain.KindOrchestrator`; if not, a typed Conflict + (`SESSION_NOT_ORCHESTRATOR`, "Only orchestrator sessions can be recreated"). + 3. Validate `rec.IsTerminated`; if still live, a typed Conflict + (reuse/`ErrNotRestorable`-style: "Session is still running"). + 4. Validate `meta.Branch != ""` (else `ErrIncompleteHandle`). + 5. Force-clean any stale worktree dir for the branch, then attach a clean + worktree on the EXISTING branch (reuse `workspace.Restore`, which already + does the existing-branch `worktree add`). + 6. Launch a FRESH orchestrator agent as a **new session** on that branch: + create a new seed session row (kind=orchestrator, same project, branch = + the existing branch), build the orchestrator system prompt + argv, then + `runtime.Create` + `lcm.MarkSpawned` (reuse Spawn's launch tail; do not + duplicate its logic — factor a shared helper if the tail is not already + callable). + 7. The old session row stays terminated. Return the NEW session record. + - **Orchestrator uniqueness:** recreate must honor whatever + one-active-orchestrator-per-project rule Spawn enforces (see + `activeOrchestratorSessionID`). Recreating from a terminated orchestrator is + allowed; if a DIFFERENT orchestrator is already live for the project, + recreate returns the same typed conflict Spawn would, rather than creating a + second live orchestrator. +- The new endpoint requires regenerating the OpenAPI spec (`npm run api`) and + will update the `httpd` spec-drift tests. This is expected and correct for a + new route (unlike the prior lifecycle plan, which added no routes). + +### Frontend + +`frontend/src/renderer/components/TerminalPane.tsx`: + +- The **"Restore session"** button stays on every terminated non-reviewer + session (the existing `canRestoreSession` trigger is unchanged). +- `restoreSession` handler, after `POST /api/v1/sessions/{id}/restore`: + - success → invalidate workspace queries + attach (existing behavior). + - error whose API code is **`SESSION_NOT_RESUMABLE`** → open a new dialog + component instead of showing the inline error. + - any other error → existing inline error display. +- New `RestoreUnavailableDialog` component (Radix Dialog, mirroring + `NewTaskDialog.tsx`; primitives from `components/ui/*`): + - Title: "Session can no longer be restored". + - Body: explains there is no saved agent session/prompt to resume from. + - If the session `kind === "orchestrator"`: primary button **"Create new + orchestrator"** → `POST /api/v1/sessions/{id}/recreate` with a loading + state; on success, invalidate workspace queries and select the returned new + session; "Cancel" closes. + - If `kind === "worker"`: explanatory text + "Close" only (no recreate). +- Detect the code via the API error body `code === "SESSION_NOT_RESUMABLE"` + (same envelope `apiErrorMessage`/error-shape the renderer already reads). + +## Data flow + +``` +User clicks "Restore session" + -> POST /sessions/{id}/restore + restorable -> 200, terminal attaches + not resumable -> 409 SESSION_NOT_RESUMABLE + -> popup opens + orchestrator -> "Create new orchestrator" + -> POST /sessions/{id}/recreate + -> clean worktree, attach existing branch, + launch fresh orchestrator as NEW session + -> 200, select new session + worker -> explanatory close-only popup +``` + +## Error handling + +- All restore/recreate failures are typed `apierr` values → correct 4xx, never a + 500 for a client-actionable condition. +- Recreate is best-effort-validated up front (kind, terminated, branch present) + so the common rejections are clean 409s, not deep wrapped errors. +- Worktree attach failures during recreate surface as the existing workspace + error kinds (e.g. branch-checked-out-elsewhere) already mapped in `toAPIError`. + +## Testing + +- **Backend unit (session_manager):** restore of a terminated session with empty + `agent_session_id`+`prompt` returns `ErrNotResumable`; `RecreateOrchestrator` + on a terminated orchestrator attaches the existing branch and returns a new + orchestrator session; rejects a live session, a worker, and a missing branch + with the typed errors. +- **Backend service:** `toAPIError(ErrNotResumable)` → 409 `SESSION_NOT_RESUMABLE`. +- **Backend httpd:** the new route appears in the OpenAPI spec; spec-drift tests + green after `npm run api`. +- **Frontend:** typecheck green; the `restoreSession` handler routes a + `SESSION_NOT_RESUMABLE` response to the dialog and a success to attach; the + dialog shows the orchestrator create button only for `kind === "orchestrator"`. +- **Manual:** on the packaged build, terminate an orchestrator that has no + resume state, click Restore, confirm the popup appears (not a 500), click + "Create new orchestrator", confirm a fresh orchestrator launches on the same + branch with history intact. + +## Files touched + +- `backend/internal/session_manager/manager.go` — `ErrNotResumable`, + `RecreateOrchestrator`, shared launch-tail helper if needed. +- `backend/internal/service/session/service.go` — `toAPIError` case, + `RecreateOrchestrator` service method. +- `backend/internal/httpd/controllers/sessions.go` (+ `dto.go`) — route + + handler + response DTO. +- `backend/internal/httpd/apispec/...` + generated spec via `npm run api`. +- `frontend/src/renderer/components/TerminalPane.tsx` — handler branch. +- `frontend/src/renderer/components/RestoreUnavailableDialog.tsx` — new dialog. + +## Constraints (binding) + +- No em dashes or en dashes anywhere (prose, comments, commit messages). +- Renderer clones the agent-orchestrator web app; build the dialog from shadcn + primitives (`components/ui/*`) and the Radix Dialog pattern already used by + `NewTaskDialog.tsx`. (See `DESIGN.md`.) +- App state under `~/.ao` only (not directly touched here). +- Do not hand-edit generated sqlc or OpenAPI output; regenerate via the npm + scripts. +- The existing resume path and the interactive dirty-refusal removal path stay + behaviorally unchanged. From 12c9489e3c3e12c72eb0823c1e3d6bb9dc7fbe09 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 07:26:10 +0530 Subject: [PATCH 41/60] docs(plan): restore-recreate orchestrator; reuse existing /orchestrators clean=true Planning discovery: the recreate capability already ships via POST /orchestrators (clean=true), which kills the dead orchestrator and re-spawns on the canonical branch (addWorktree reattaches an existing branch). So the feature collapses to a typed-error fix plus a frontend popup. Spec updated to match. Co-Authored-By: Claude Opus 4.8 --- ...026-06-24-restore-recreate-orchestrator.md | 367 ++++++++++++++++++ ...24-restore-recreate-orchestrator-design.md | 106 ++--- 2 files changed, 421 insertions(+), 52 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-24-restore-recreate-orchestrator.md diff --git a/docs/superpowers/plans/2026-06-24-restore-recreate-orchestrator.md b/docs/superpowers/plans/2026-06-24-restore-recreate-orchestrator.md new file mode 100644 index 00000000..6811fa9f --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-restore-recreate-orchestrator.md @@ -0,0 +1,367 @@ +# Restore Recreate Orchestrator Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the opaque 500 on restoring an un-resumable session into a typed 409, and add a popup (shown only after a failed restore) that offers to recreate a fresh orchestrator on the same branch. + +**Architecture:** One small backend change (a typed sentinel error + its 409 mapping) plus a frontend popup. The recreate action reuses the EXISTING `POST /api/v1/orchestrators {clean:true}` endpoint, which already kills the dead orchestrator and re-spawns one on the canonical branch (reattaching the existing branch with history). No new backend route, manager method, or OpenAPI regeneration. + +**Tech Stack:** Go backend (session_manager + service/session), React + TypeScript renderer (Radix Dialog, openapi-fetch api client, vitest). + +## Global Constraints + +- No em dashes or en dashes anywhere (prose, comments, commit messages). Use periods, commas, colons, semicolons, parentheses. +- The renderer clones the agent-orchestrator web app; build UI from shadcn primitives (`components/ui/*`) and the Radix Dialog pattern already used by `NewTaskDialog.tsx`. +- App state under `~/.ao` only (not touched here). +- The existing resume path and the interactive dirty-refusal removal path stay behaviorally unchanged. +- Do not hand-edit generated sqlc/OpenAPI output. This feature adds no routes, so no regeneration is needed. +- Git author is already configured (dev@theharshitsingh.com); add `Co-Authored-By: Claude Opus 4.8 ` to commits. + +--- + +### Task 1: Typed error for un-resumable restore (fixes the 500) + +**Files:** +- Modify: `backend/internal/session_manager/manager.go` (sentinel near line 25; the "nothing to resume from" return at line 480) +- Modify: `backend/internal/service/session/service.go` (`toAPIError`, near line 450) +- Test: `backend/internal/service/session/service_test.go` (new test for the mapping) + +**Interfaces:** +- Produces: `sessionmanager.ErrNotResumable` (a sentinel `error`), and the wire contract `409` with code `SESSION_NOT_RESUMABLE` from `POST /api/v1/sessions/{id}/restore` when a terminated session has neither `agent_session_id` nor `prompt`. Task 2 (frontend) consumes the `SESSION_NOT_RESUMABLE` code. + +- [ ] **Step 1: Write the failing test** + +In `backend/internal/service/session/service_test.go`, add (mirror the package's existing test style and imports; `apierr` is `backend/internal/apierr`, `sessionmanager` is the session_manager package alias already used in `service.go`): + +```go +func TestToAPIError_NotResumable(t *testing.T) { + err := toAPIError(fmt.Errorf("restore foo: %w", sessionmanager.ErrNotResumable)) + var ae *apierr.Error + if !errors.As(err, &ae) { + t.Fatalf("want *apierr.Error, got %T: %v", err, err) + } + if ae.Kind != apierr.KindConflict { + t.Errorf("kind = %v, want %v", ae.Kind, apierr.KindConflict) + } + if ae.Code != "SESSION_NOT_RESUMABLE" { + t.Errorf("code = %q, want SESSION_NOT_RESUMABLE", ae.Code) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./internal/service/session/ -run TestToAPIError_NotResumable` +Expected: FAIL to COMPILE with `undefined: sessionmanager.ErrNotResumable`. + +- [ ] **Step 3: Add the sentinel** + +In `backend/internal/session_manager/manager.go`, in the `var (...)` error block near line 25 (next to `ErrNotRestorable`, `ErrIncompleteHandle`), add: + +```go + // ErrNotResumable means a terminated session has no saved agent session id + // and no prompt, so there is nothing for Restore to relaunch from. Distinct + // from ErrNotRestorable (which is "not terminal yet"). + ErrNotResumable = errors.New("session: nothing to resume from") +``` + +- [ ] **Step 4: Return the sentinel from Restore** + +In `backend/internal/session_manager/manager.go`, change the plain error at line 480 (inside `Restore`) from: + +```go + if meta.AgentSessionID == "" && meta.Prompt == "" { + return domain.SessionRecord{}, fmt.Errorf("restore %s: nothing to resume from", id) + } +``` + +to: + +```go + if meta.AgentSessionID == "" && meta.Prompt == "" { + return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, ErrNotResumable) + } +``` + +- [ ] **Step 5: Map the sentinel in `toAPIError`** + +In `backend/internal/service/session/service.go`, inside `toAPIError`, add a case alongside the sibling cases (after the `ErrIncompleteHandle` case, around line 455): + +```go + case errors.Is(err, sessionmanager.ErrNotResumable): + return apierr.Conflict("SESSION_NOT_RESUMABLE", + "This session has no saved agent session or prompt to resume from", nil) +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `cd backend && go test ./internal/service/session/ -run TestToAPIError_NotResumable` +Expected: PASS. + +- [ ] **Step 7: Build, vet, and run the touched packages** + +Run: `cd backend && go build ./... && go vet ./internal/session_manager/... ./internal/service/session/... && go test ./internal/session_manager/... ./internal/service/session/...` +Expected: build clean, vet clean, all tests PASS (no behavior change to existing restore tests since the error value still wraps the same condition). + +- [ ] **Step 8: Commit** + +```bash +git add backend/internal/session_manager/manager.go backend/internal/service/session/service.go backend/internal/service/session/service_test.go +git commit -m "fix(session): return typed SESSION_NOT_RESUMABLE instead of 500 on un-resumable restore + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 2: Restore-unavailable popup + recreate via existing orchestrator endpoint + +**Files:** +- Modify: `frontend/src/renderer/lib/spawn-orchestrator.ts` (optional `clean` param) +- Create: `frontend/src/renderer/components/RestoreUnavailableDialog.tsx` (the popup) +- Modify: `frontend/src/renderer/components/TerminalPane.tsx` (route `SESSION_NOT_RESUMABLE` to the dialog) +- Test: `frontend/src/renderer/lib/spawn-orchestrator.test.ts` (new; clean param) + +**Interfaces:** +- Consumes from Task 1: the restore response error envelope `{ code: "SESSION_NOT_RESUMABLE", message, ... }`. +- Consumes existing: `spawnOrchestrator(projectId, clean?)` (extended here), `isOrchestrator(session)` from `frontend/src/renderer/types/workspace.ts`, `apiClient`/`apiErrorMessage` from `lib/api-client`, `workspaceQueryKey` already imported in `TerminalPane.tsx`. +- Produces: `RestoreUnavailableDialog` React component with props `{ open: boolean; session: SessionView; onOpenChange: (open: boolean) => void; onRecreated: (newOrchestratorId: string) => void }`. + +- [ ] **Step 1: Write the failing test for the `clean` param** + +Create `frontend/src/renderer/lib/spawn-orchestrator.test.ts` (mirror the mocking style in existing `frontend/src/renderer/**/*.test.ts(x)`; vitest is already configured): + +```ts +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { spawnOrchestrator } from "./spawn-orchestrator"; +import { apiClient } from "./api-client"; + +vi.mock("./api-client", () => ({ + apiClient: { POST: vi.fn() }, +})); + +describe("spawnOrchestrator", () => { + beforeEach(() => vi.clearAllMocks()); + + it("sends clean:true through to the request body when asked", async () => { + (apiClient.POST as ReturnType).mockResolvedValue({ + data: { orchestrator: { id: "proj-9" } }, + error: undefined, + response: { status: 201 }, + }); + const id = await spawnOrchestrator("proj", true); + expect(id).toBe("proj-9"); + expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", { + body: { projectId: "proj", clean: true }, + }); + }); + + it("defaults clean to false / omitted for the existing call sites", async () => { + (apiClient.POST as ReturnType).mockResolvedValue({ + data: { orchestrator: { id: "proj-1" } }, + error: undefined, + response: { status: 201 }, + }); + await spawnOrchestrator("proj"); + expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", { + body: { projectId: "proj", clean: false }, + }); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd frontend && npx vitest run src/renderer/lib/spawn-orchestrator.test.ts` +Expected: FAIL (current helper sends `{ projectId }` with no `clean`). + +- [ ] **Step 3: Add the `clean` param to the helper** + +Edit `frontend/src/renderer/lib/spawn-orchestrator.ts`: + +```ts +import { apiClient } from "./api-client"; + +/** Spawn the project's orchestrator session via the daemon API. When clean is + * true the daemon first tears down any active orchestrator for the project, then + * re-spawns one on the canonical branch (reattaching the existing branch). */ +export async function spawnOrchestrator(projectId: string, clean = false): Promise { + const { data, error, response } = await apiClient.POST("/api/v1/orchestrators", { + body: { projectId, clean }, + }); + + if (error || !data?.orchestrator?.id) { + const message = + error && typeof error === "object" && "message" in error && typeof error.message === "string" + ? error.message + : `Failed to spawn orchestrator (${response.status})`; + throw new Error(message); + } + + return data.orchestrator.id; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd frontend && npx vitest run src/renderer/lib/spawn-orchestrator.test.ts` +Expected: PASS (both cases). + +- [ ] **Step 5: Create the popup component** + +Create `frontend/src/renderer/components/RestoreUnavailableDialog.tsx` (mirror the Radix Dialog structure/styling of `NewTaskDialog.tsx`; reuse `Button` from `./ui/button`): + +```tsx +import * as Dialog from "@radix-ui/react-dialog"; +import { Loader2 } from "lucide-react"; +import { useState } from "react"; +import { Button } from "./ui/button"; +import { spawnOrchestrator } from "../lib/spawn-orchestrator"; +import { isOrchestrator } from "../types/workspace"; +import type { SessionView } from "../types/workspace"; + +type RestoreUnavailableDialogProps = { + open: boolean; + session: SessionView; + onOpenChange: (open: boolean) => void; + onRecreated: (newOrchestratorId: string) => void; +}; + +export function RestoreUnavailableDialog({ open, session, onOpenChange, onRecreated }: RestoreUnavailableDialogProps) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const orchestrator = isOrchestrator(session); + + const recreate = async () => { + setBusy(true); + setError(undefined); + try { + const id = await spawnOrchestrator(session.projectId, true); + onOpenChange(false); + onRecreated(id); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create orchestrator"); + } finally { + setBusy(false); + } + }; + + return ( + + + + + + Session can no longer be restored + + + {orchestrator + ? "This orchestrator has no saved agent session to resume. You can create a new orchestrator on the same branch; its committed work is preserved and the old worktree is cleaned." + : "This session has no saved agent session or prompt to resume from."} + + {error &&
{error}
} +
+ + {orchestrator && ( + + )} +
+
+
+
+ ); +} +``` + +Note: confirm `Button` supports the `variant="ghost"` prop (check `./ui/button`); if its variant names differ, use the existing equivalent for a secondary/cancel button. Confirm `SessionView` exposes `projectId` and `kind`; if `projectId` is named differently on the view type, use the actual field. + +- [ ] **Step 6: Wire the restore handler in `TerminalPane.tsx`** + +In `frontend/src/renderer/components/TerminalPane.tsx`, add state and a dialog mount, and branch the restore error on the `SESSION_NOT_RESUMABLE` code. The existing handler is `restoreSession` (around lines 85-100) and the error is `restoreError` from `apiClient.POST(".../restore")`. + +Add state near the other `useState` hooks in `AttachedTerminal`: + +```tsx + const [restoreUnavailable, setRestoreUnavailable] = useState(false); +``` + +Replace the `catch`/error handling inside `restoreSession` so a `SESSION_NOT_RESUMABLE` code opens the dialog instead of setting the inline error. The `restoreError` returned by `apiClient.POST` is the parsed error envelope, so read its `code`: + +```tsx + try { + const { error: restoreError } = await apiClient.POST("/api/v1/sessions/{sessionId}/restore", { + params: { path: { sessionId: session.id } }, + }); + if (restoreError) { + const code = (restoreError as { code?: string }).code; + if (code === "SESSION_NOT_RESUMABLE") { + setRestoreUnavailable(true); + return; + } + throw new Error(apiErrorMessage(restoreError, "Unable to restore session")); + } + await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + } catch (err) { + setRestoreError(err instanceof Error ? err.message : "Unable to restore session"); + } finally { + setIsRestoring(false); + } +``` + +Mount the dialog inside the component's returned JSX (e.g. just before the closing tag of the root `div` in `AttachedTerminal`, alongside the other absolutely-positioned children): + +```tsx + {session && ( + { + await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + }} + /> + )} +``` + +Add the import at the top of the file: + +```tsx +import { RestoreUnavailableDialog } from "./RestoreUnavailableDialog"; +``` + +Note: `onRecreated` here just refreshes the workspace so the new orchestrator appears in the list. If the renderer has an existing "select session" mechanism reachable from this component, call it with the new id; otherwise the invalidate is sufficient and the user picks the new orchestrator from the refreshed list. Do not invent a selection API that does not exist. + +- [ ] **Step 7: Typecheck and run the frontend tests** + +Run: `cd frontend && npx tsc --noEmit 2>&1 | grep -v "forge.config" ; npx vitest run src/renderer/lib/spawn-orchestrator.test.ts` +Expected: no NEW typecheck errors (only the pre-existing `forge.config.ts` `osxNotarize` error is acceptable); vitest PASS. + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/renderer/lib/spawn-orchestrator.ts frontend/src/renderer/lib/spawn-orchestrator.test.ts frontend/src/renderer/components/RestoreUnavailableDialog.tsx frontend/src/renderer/components/TerminalPane.tsx +git commit -m "feat(renderer): offer recreate-orchestrator popup when a session cannot be restored + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Manual verification (after both tasks, requires a rebuild of the packaged app) + +1. `cd frontend && export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"; nvm use 22.17.0; npm run make` (signed/notarized build per the release runbook) or run the dev app. +2. Terminate an orchestrator that has no saved agent session (e.g. a stale one), so the UI shows its "Restore session" button. +3. Click "Restore session". Expected: a popup appears (NOT "Internal server error"), titled "Session can no longer be restored". +4. Click "Create new orchestrator". Expected: a fresh orchestrator launches on the same `ao/-orchestrator` branch with its committed history intact, and appears in the session list. +5. Confirm a worker that cannot be restored shows the same popup with a Close-only button (no recreate). + +## Self-review notes + +- Spec coverage: Task 1 covers the typed-error fix (spec Backend #1); Task 2 covers the popup + recreate via the existing `/orchestrators` endpoint (spec Backend #2 reuse + Frontend). The spec's "no new endpoint / no OpenAPI regen" is honored. +- Type consistency: `ErrNotResumable` (Task 1) is the symbol consumed by `toAPIError`; `SESSION_NOT_RESUMABLE` is the wire code consumed by Task 2's handler; `spawnOrchestrator(projectId, clean)` signature is defined in Task 2 Step 3 and consumed in Step 5. +- The two `Note:` callouts (Button variant name, SessionView `projectId` field, selection API) flag the only spots where the exact local name must be confirmed against the codebase during implementation; the implementer verifies rather than guessing. diff --git a/docs/superpowers/specs/2026-06-24-restore-recreate-orchestrator-design.md b/docs/superpowers/specs/2026-06-24-restore-recreate-orchestrator-design.md index 7998ce0a..bafebb06 100644 --- a/docs/superpowers/specs/2026-06-24-restore-recreate-orchestrator-design.md +++ b/docs/superpowers/specs/2026-06-24-restore-recreate-orchestrator-design.md @@ -87,36 +87,34 @@ must REUSE the existing branch, so it goes through the existing-branch attach "This session has no saved agent session or prompt to resume from", nil) ``` -#### 2. Recreate endpoint (orchestrator-only) -- Route: `POST /api/v1/sessions/{sessionId}/recreate`, registered next to the - other session routes in `httpd/controllers/sessions.go`, handled by a new - `SessionsController.recreate` that calls `Svc.RecreateOrchestrator`. -- Service method `RecreateOrchestrator(ctx, id) (domain.Session, error)` wraps - the manager method and runs its error through `toAPIError`. -- Manager method `RecreateOrchestrator(ctx, id domain.SessionID) - (domain.SessionRecord, error)`: - 1. `GetSession`; 404 (`ErrNotFound`) if absent. - 2. Validate `rec.Kind == domain.KindOrchestrator`; if not, a typed Conflict - (`SESSION_NOT_ORCHESTRATOR`, "Only orchestrator sessions can be recreated"). - 3. Validate `rec.IsTerminated`; if still live, a typed Conflict - (reuse/`ErrNotRestorable`-style: "Session is still running"). - 4. Validate `meta.Branch != ""` (else `ErrIncompleteHandle`). - 5. Force-clean any stale worktree dir for the branch, then attach a clean - worktree on the EXISTING branch (reuse `workspace.Restore`, which already - does the existing-branch `worktree add`). - 6. Launch a FRESH orchestrator agent as a **new session** on that branch: - create a new seed session row (kind=orchestrator, same project, branch = - the existing branch), build the orchestrator system prompt + argv, then - `runtime.Create` + `lcm.MarkSpawned` (reuse Spawn's launch tail; do not - duplicate its logic — factor a shared helper if the tail is not already - callable). - 7. The old session row stays terminated. Return the NEW session record. - - **Orchestrator uniqueness:** recreate must honor whatever - one-active-orchestrator-per-project rule Spawn enforces (see - `activeOrchestratorSessionID`). Recreating from a terminated orchestrator is - allowed; if a DIFFERENT orchestrator is already live for the project, - recreate returns the same typed conflict Spawn would, rather than creating a - second live orchestrator. +#### 2. Recreate: REUSE the existing `POST /api/v1/orchestrators` (clean=true) +**Discovery during planning:** the recreate capability already ships. No new +endpoint or manager method is needed. + +- `SessionsController.spawnOrchestrator` already handles `POST /api/v1/orchestrators` + with body `{projectId, clean}` (`httpd/controllers/sessions.go`). +- `Service.SpawnOrchestrator(ctx, projectID, clean)` + (`service/session/service.go:263`): when `clean` is true it kills any active + orchestrators for the project, then `Spawn(SpawnConfig{ProjectID, Kind: + orchestrator})`. +- `Spawn` with no branch defaults to the canonical orchestrator branch + `ao/-orchestrator` (`defaultSessionBranch`). That is the SAME branch + the dead orchestrator used. +- `workspace.Create` -> `addWorktree` + (`adapters/workspace/gitworktree/workspace.go`) already detects an EXISTING + local branch (`refExists("refs/heads/"+branch)`) and attaches it with the + no-`-b` `worktreeAddBranchArgs` (preserving committed history); it only uses + `-b` for a genuinely new branch, and refuses with `ErrBranchCheckedOutElsewhere` + (409) if the branch is live in another worktree. + +So "create a new orchestrator on the same branch, cleaning the old worktree" = +`POST /api/v1/orchestrators {projectId, clean:true}`. The `clean` kill frees the +dead orchestrator's worktree; the re-spawn reattaches the existing branch. The +old session row stays terminated; a new orchestrator session id is returned. +Orchestrator uniqueness is already enforced by the `clean` kill-then-spawn rule. + +The ONLY backend change in this feature is item #1 (the typed error). No new +route, no `RecreateOrchestrator`, no OpenAPI/spec regen. - The new endpoint requires regenerating the OpenAPI spec (`npm run api`) and will update the `httpd` spec-drift tests. This is expected and correct for a new route (unlike the prior lifecycle plan, which added no routes). @@ -137,12 +135,17 @@ must REUSE the existing branch, so it goes through the existing-branch attach - Title: "Session can no longer be restored". - Body: explains there is no saved agent session/prompt to resume from. - If the session `kind === "orchestrator"`: primary button **"Create new - orchestrator"** → `POST /api/v1/sessions/{id}/recreate` with a loading - state; on success, invalidate workspace queries and select the returned new - session; "Cancel" closes. + orchestrator"** → calls the existing `spawnOrchestrator` helper + (`frontend/src/renderer/lib/spawn-orchestrator.ts`) extended with a `clean` + argument: `spawnOrchestrator(projectId, true)` → `POST /api/v1/orchestrators + {projectId, clean:true}`, with a loading state; on success, invalidate + workspace queries and select the returned new orchestrator id; "Cancel" + closes. - If `kind === "worker"`: explanatory text + "Close" only (no recreate). - Detect the code via the API error body `code === "SESSION_NOT_RESUMABLE"` (same envelope `apiErrorMessage`/error-shape the renderer already reads). +- `spawn-orchestrator.ts` gains an optional `clean = false` parameter passed + through to the request body; the existing single-arg call sites are unchanged. ## Data flow @@ -153,10 +156,11 @@ User clicks "Restore session" not resumable -> 409 SESSION_NOT_RESUMABLE -> popup opens orchestrator -> "Create new orchestrator" - -> POST /sessions/{id}/recreate - -> clean worktree, attach existing branch, - launch fresh orchestrator as NEW session - -> 200, select new session + -> POST /api/v1/orchestrators {projectId, clean:true} + (existing endpoint: kills active orchestrator, + re-spawns on canonical branch, reattaches + existing branch with history) + -> 201, select new orchestrator worker -> explanatory close-only popup ``` @@ -172,16 +176,12 @@ User clicks "Restore session" ## Testing - **Backend unit (session_manager):** restore of a terminated session with empty - `agent_session_id`+`prompt` returns `ErrNotResumable`; `RecreateOrchestrator` - on a terminated orchestrator attaches the existing branch and returns a new - orchestrator session; rejects a live session, a worker, and a missing branch - with the typed errors. + `agent_session_id`+`prompt` returns `ErrNotResumable`. - **Backend service:** `toAPIError(ErrNotResumable)` → 409 `SESSION_NOT_RESUMABLE`. -- **Backend httpd:** the new route appears in the OpenAPI spec; spec-drift tests - green after `npm run api`. - **Frontend:** typecheck green; the `restoreSession` handler routes a `SESSION_NOT_RESUMABLE` response to the dialog and a success to attach; the - dialog shows the orchestrator create button only for `kind === "orchestrator"`. + dialog shows the orchestrator create button only for `kind === "orchestrator"`; + `spawnOrchestrator(projectId, true)` sends `clean:true`. - **Manual:** on the packaged build, terminate an orchestrator that has no resume state, click Restore, confirm the popup appears (not a 500), click "Create new orchestrator", confirm a fresh orchestrator launches on the same @@ -189,16 +189,18 @@ User clicks "Restore session" ## Files touched -- `backend/internal/session_manager/manager.go` — `ErrNotResumable`, - `RecreateOrchestrator`, shared launch-tail helper if needed. -- `backend/internal/service/session/service.go` — `toAPIError` case, - `RecreateOrchestrator` service method. -- `backend/internal/httpd/controllers/sessions.go` (+ `dto.go`) — route + - handler + response DTO. -- `backend/internal/httpd/apispec/...` + generated spec via `npm run api`. -- `frontend/src/renderer/components/TerminalPane.tsx` — handler branch. +- `backend/internal/session_manager/manager.go` — `ErrNotResumable` sentinel + + use it at the "nothing to resume from" return. +- `backend/internal/service/session/service.go` — `toAPIError` case for + `ErrNotResumable` → 409 `SESSION_NOT_RESUMABLE`. +- `frontend/src/renderer/lib/spawn-orchestrator.ts` — optional `clean` param. +- `frontend/src/renderer/components/TerminalPane.tsx` — restore handler routes + `SESSION_NOT_RESUMABLE` to the dialog. - `frontend/src/renderer/components/RestoreUnavailableDialog.tsx` — new dialog. +No new backend route, manager method, or OpenAPI regeneration: the recreate +reuses the existing `POST /api/v1/orchestrators` (clean=true) path. + ## Constraints (binding) - No em dashes or en dashes anywhere (prose, comments, commit messages). From 9c97ba3bfd828f2b6fa4287fbbc77c0d00d200ea Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 07:29:41 +0530 Subject: [PATCH 42/60] fix(session): return typed SESSION_NOT_RESUMABLE instead of 500 on un-resumable restore Co-Authored-By: Claude Opus 4.8 --- backend/internal/service/session/service.go | 3 +++ backend/internal/service/session/service_test.go | 14 ++++++++++++++ backend/internal/session_manager/manager.go | 6 +++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 090c11b9..facfc7bd 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -453,6 +453,9 @@ func toAPIError(err error) error { return apierr.Conflict("SESSION_TERMINATED", "Session is terminated", nil) case errors.Is(err, sessionmanager.ErrIncompleteHandle): return apierr.Conflict("SESSION_INCOMPLETE_HANDLE", "Session is missing runtime or workspace handles", nil) + case errors.Is(err, sessionmanager.ErrNotResumable): + return apierr.Conflict("SESSION_NOT_RESUMABLE", + "This session has no saved agent session or prompt to resume from", nil) case errors.Is(err, sessionmanager.ErrProjectNotResolvable): return apierr.Invalid("PROJECT_NOT_RESOLVABLE", "Project is not registered or has no repo — register it with `ao project add`", nil) case errors.Is(err, sessionmanager.ErrUnknownHarness): diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index 81f02d29..3b0476f2 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -858,3 +858,17 @@ func containsString(values []string, want string) bool { } return false } + +func TestToAPIError_NotResumable(t *testing.T) { + err := toAPIError(fmt.Errorf("restore foo: %w", sessionmanager.ErrNotResumable)) + var ae *apierr.Error + if !errors.As(err, &ae) { + t.Fatalf("want *apierr.Error, got %T: %v", err, err) + } + if ae.Kind != apierr.KindConflict { + t.Errorf("kind = %v, want %v", ae.Kind, apierr.KindConflict) + } + if ae.Code != "SESSION_NOT_RESUMABLE" { + t.Errorf("code = %q, want SESSION_NOT_RESUMABLE", ae.Code) + } +} diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 6ca496f6..4698e0d1 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -25,6 +25,10 @@ var ( ErrNotRestorable = errors.New("session: not restorable (not terminal)") ErrTerminated = errors.New("session: terminated") ErrIncompleteHandle = errors.New("session: incomplete teardown handle") + // ErrNotResumable means a terminated session has no saved agent session id + // and no prompt, so there is nothing for Restore to relaunch from. Distinct + // from ErrNotRestorable (which is "not terminal yet"). + ErrNotResumable = errors.New("session: nothing to resume from") // ErrProjectNotResolvable means the spawn's project has no usable repo // (unregistered, archived, or missing a path). The API maps it to a 400. ErrProjectNotResolvable = errors.New("session: project repo not resolvable") @@ -477,7 +481,7 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, ErrIncompleteHandle) } if meta.AgentSessionID == "" && meta.Prompt == "" { - return domain.SessionRecord{}, fmt.Errorf("restore %s: nothing to resume from", id) + return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, ErrNotResumable) } project, err := m.loadProject(ctx, rec.ProjectID) From b0923ce256d1ecadafa72cca0aaca3b0f9dba2e7 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 07:34:34 +0530 Subject: [PATCH 43/60] feat(renderer): offer recreate-orchestrator popup when a session cannot be restored Co-Authored-By: Claude Opus 4.8 --- .../components/RestoreUnavailableDialog.tsx | 64 +++++++++++++++++++ .../src/renderer/components/TerminalPane.tsx | 21 +++++- .../renderer/lib/spawn-orchestrator.test.ts | 36 +++++++++++ .../src/renderer/lib/spawn-orchestrator.ts | 8 ++- 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 frontend/src/renderer/components/RestoreUnavailableDialog.tsx create mode 100644 frontend/src/renderer/lib/spawn-orchestrator.test.ts diff --git a/frontend/src/renderer/components/RestoreUnavailableDialog.tsx b/frontend/src/renderer/components/RestoreUnavailableDialog.tsx new file mode 100644 index 00000000..98dbda5f --- /dev/null +++ b/frontend/src/renderer/components/RestoreUnavailableDialog.tsx @@ -0,0 +1,64 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import { Loader2 } from "lucide-react"; +import { useState } from "react"; +import { Button } from "./ui/button"; +import { spawnOrchestrator } from "../lib/spawn-orchestrator"; +import { isOrchestratorSession } from "../types/workspace"; +import type { WorkspaceSession } from "../types/workspace"; + +type RestoreUnavailableDialogProps = { + open: boolean; + session: WorkspaceSession; + onOpenChange: (open: boolean) => void; + onRecreated: (newOrchestratorId: string) => void; +}; + +export function RestoreUnavailableDialog({ open, session, onOpenChange, onRecreated }: RestoreUnavailableDialogProps) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const orchestrator = isOrchestratorSession(session); + + const recreate = async () => { + setBusy(true); + setError(undefined); + try { + const id = await spawnOrchestrator(session.workspaceId, true); + onOpenChange(false); + onRecreated(id); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create orchestrator"); + } finally { + setBusy(false); + } + }; + + return ( + + + + + + Session can no longer be restored + + + {orchestrator + ? "This orchestrator has no saved agent session to resume. You can create a new orchestrator on the same branch; its committed work is preserved and the old worktree is cleaned." + : "This session has no saved agent session or prompt to resume from."} + + {error &&
{error}
} +
+ + {orchestrator && ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/renderer/components/TerminalPane.tsx b/frontend/src/renderer/components/TerminalPane.tsx index 841762e8..5e61254d 100644 --- a/frontend/src/renderer/components/TerminalPane.tsx +++ b/frontend/src/renderer/components/TerminalPane.tsx @@ -7,6 +7,7 @@ import { useTerminalSession, type AttachableTerminal, type TerminalSessionState import { apiClient, apiErrorMessage } from "../lib/api-client"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { XtermTerminal } from "./XtermTerminal"; +import { RestoreUnavailableDialog } from "./RestoreUnavailableDialog"; type TerminalPaneProps = { session?: WorkspaceSession; @@ -69,6 +70,7 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz const [initFailed, setInitFailed] = useState(false); const [isRestoring, setIsRestoring] = useState(false); const [restoreError, setRestoreError] = useState(); + const [restoreUnavailable, setRestoreUnavailable] = useState(false); const queryClient = useQueryClient(); const { attach, state, error } = useTerminalSession(attachSession, { daemonReady }); const handleId = attachSession?.terminalHandleId; @@ -90,7 +92,14 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz const { error: restoreError } = await apiClient.POST("/api/v1/sessions/{sessionId}/restore", { params: { path: { sessionId: session.id } }, }); - if (restoreError) throw new Error(apiErrorMessage(restoreError, "Unable to restore session")); + if (restoreError) { + const code = (restoreError as { code?: string }).code; + if (code === "SESSION_NOT_RESUMABLE") { + setRestoreUnavailable(true); + return; + } + throw new Error(apiErrorMessage(restoreError, "Unable to restore session")); + } await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); } catch (err) { setRestoreError(err instanceof Error ? err.message : "Unable to restore session"); @@ -163,6 +172,16 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz )} + {session && ( + { + await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + }} + /> + )} ); } diff --git a/frontend/src/renderer/lib/spawn-orchestrator.test.ts b/frontend/src/renderer/lib/spawn-orchestrator.test.ts new file mode 100644 index 00000000..34475155 --- /dev/null +++ b/frontend/src/renderer/lib/spawn-orchestrator.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { spawnOrchestrator } from "./spawn-orchestrator"; +import { apiClient } from "./api-client"; + +vi.mock("./api-client", () => ({ + apiClient: { POST: vi.fn() }, +})); + +describe("spawnOrchestrator", () => { + beforeEach(() => vi.clearAllMocks()); + + it("sends clean:true through to the request body when asked", async () => { + (apiClient.POST as ReturnType).mockResolvedValue({ + data: { orchestrator: { id: "proj-9" } }, + error: undefined, + response: { status: 201 }, + }); + const id = await spawnOrchestrator("proj", true); + expect(id).toBe("proj-9"); + expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", { + body: { projectId: "proj", clean: true }, + }); + }); + + it("defaults clean to false / omitted for the existing call sites", async () => { + (apiClient.POST as ReturnType).mockResolvedValue({ + data: { orchestrator: { id: "proj-1" } }, + error: undefined, + response: { status: 201 }, + }); + await spawnOrchestrator("proj"); + expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", { + body: { projectId: "proj", clean: false }, + }); + }); +}); diff --git a/frontend/src/renderer/lib/spawn-orchestrator.ts b/frontend/src/renderer/lib/spawn-orchestrator.ts index 15d73b86..1a0c2c81 100644 --- a/frontend/src/renderer/lib/spawn-orchestrator.ts +++ b/frontend/src/renderer/lib/spawn-orchestrator.ts @@ -1,9 +1,11 @@ import { apiClient } from "./api-client"; -/** Spawn the project's orchestrator session via the daemon API. */ -export async function spawnOrchestrator(projectId: string): Promise { +/** Spawn the project's orchestrator session via the daemon API. When clean is + * true the daemon first tears down any active orchestrator for the project, then + * re-spawns one on the canonical branch (reattaching the existing branch). */ +export async function spawnOrchestrator(projectId: string, clean = false): Promise { const { data, error, response } = await apiClient.POST("/api/v1/orchestrators", { - body: { projectId }, + body: { projectId, clean }, }); if (error || !data?.orchestrator?.id) { From 16ab31106942b85f79a68cd4fb6e3f5d479662f2 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 07:40:32 +0530 Subject: [PATCH 44/60] docs(spec): drop stale OpenAPI-regen note (feature adds no route) Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-06-24-restore-recreate-orchestrator-design.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/superpowers/specs/2026-06-24-restore-recreate-orchestrator-design.md b/docs/superpowers/specs/2026-06-24-restore-recreate-orchestrator-design.md index bafebb06..3adcef75 100644 --- a/docs/superpowers/specs/2026-06-24-restore-recreate-orchestrator-design.md +++ b/docs/superpowers/specs/2026-06-24-restore-recreate-orchestrator-design.md @@ -115,9 +115,6 @@ Orchestrator uniqueness is already enforced by the `clean` kill-then-spawn rule. The ONLY backend change in this feature is item #1 (the typed error). No new route, no `RecreateOrchestrator`, no OpenAPI/spec regen. -- The new endpoint requires regenerating the OpenAPI spec (`npm run api`) and - will update the `httpd` spec-drift tests. This is expected and correct for a - new route (unlike the prior lifecycle plan, which added no routes). ### Frontend From e69dc921ad79119840586910b91a08be92fb705c Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 08:12:39 +0530 Subject: [PATCH 45/60] fix(ci): gofmt/goimports, golangci-lint hygiene, and Windows-aware doctor tests Formatting: ran gofmt and goimports (with local-prefixes) on the 8 listed files plus ptyexec/spawn_unix.go which the linter also flagged. Lint (25 issues fixed): - gosec G115: EncodeMessage now returns ([]byte, error) with an explicit bounds check before the int->uint32 conversion; all callers updated. - govet nilness: removed dead `if lastErr == nil` branch in clientIsAlive; lastErr is provably non-nil at that point (real bug). - nilerr: extracted runAcceptLoop helper so Accept-error-on-close is not flagged; listener close is normal shutdown, not a caller error. - staticcheck SA4010: removed dead `full = append(...)` loop in host_test. - revive var-declaration: `var prev int = -1` -> `prev := -1`. - revive redefines-builtin-id: deleted local `min` helper; builtin covers it. - unparam (2): dropped always-nil env return from attachCommand; dropped unused shellPath param from buildLaunchCommand; updated callers. - errcheck (8): deferred Close/Remove calls wrapped in func(){_ = ...}(); type assertion in host_main.go uses ok-form; fmt.Fprintf to stdout uses _, _ = pattern; workspace.go tmpIdx.Close() uses _ =. - gocritic nestingReduce: inverted if+continue in runtime.go resolve loop. Windows E2E: skip TestDoctorChecksTmuxVersion, TestDoctorChecksTmuxVersionFailsOnError, TestDoctorWarnsWhenTmuxMissing on windows (ao doctor emits a conpty check there, not tmux). Verified: gofmt -l . clean, golangci-lint 0 issues, go build ok, go test -race 1624/1624 pass. Co-Authored-By: Claude Opus 4.8 --- .../adapters/runtime/conpty/attach.go | 12 ++++- .../adapters/runtime/conpty/client.go | 32 +++++++------ .../internal/adapters/runtime/conpty/host.go | 46 +++++++++++++------ .../runtime/conpty/host_conpty_windows.go | 8 ++-- .../adapters/runtime/conpty/host_main.go | 10 +++- .../adapters/runtime/conpty/host_test.go | 33 ++++++++----- .../internal/adapters/runtime/conpty/proto.go | 20 ++++++-- .../adapters/runtime/conpty/proto_test.go | 30 ++++++++---- .../adapters/runtime/conpty/runtime.go | 25 +++++----- .../adapters/runtime/conpty/runtime_test.go | 8 ---- .../adapters/runtime/ptyexec/spawn_unix.go | 3 +- .../internal/adapters/runtime/tmux/tmux.go | 24 +++++----- .../adapters/runtime/tmux/tmux_test.go | 11 ++--- .../workspace/gitworktree/workspace.go | 4 +- .../workspace_forcedestroy_test.go | 4 +- backend/internal/cli/doctor_test.go | 10 ++++ backend/internal/cli/ptyhost.go | 6 +-- backend/internal/daemon/wiring_test.go | 1 - .../internal/session_manager/manager_test.go | 8 ++-- 19 files changed, 179 insertions(+), 116 deletions(-) diff --git a/backend/internal/adapters/runtime/conpty/attach.go b/backend/internal/adapters/runtime/conpty/attach.go index c2e83632..a8f11ef2 100644 --- a/backend/internal/adapters/runtime/conpty/attach.go +++ b/backend/internal/adapters/runtime/conpty/attach.go @@ -90,7 +90,11 @@ func (s *loopbackStream) pump() { func (s *loopbackStream) Read(p []byte) (int, error) { return s.pr.Read(p) } func (s *loopbackStream) Write(p []byte) (int, error) { - if _, err := s.conn.Write(EncodeMessage(MsgTerminalInput, p)); err != nil { + frame, err := EncodeMessage(MsgTerminalInput, p) + if err != nil { + return 0, err + } + if _, err := s.conn.Write(frame); err != nil { return 0, err } return len(p), nil @@ -98,7 +102,11 @@ func (s *loopbackStream) Write(p []byte) (int, error) { func (s *loopbackStream) Resize(rows, cols uint16) error { payload, _ := json.Marshal(ResizePayload{Cols: int(cols), Rows: int(rows)}) - _, err := s.conn.Write(EncodeMessage(MsgResize, payload)) + frame, err := EncodeMessage(MsgResize, payload) // small JSON payload, never overflows uint32 + if err != nil { + return err + } + _, err = s.conn.Write(frame) return err } diff --git a/backend/internal/adapters/runtime/conpty/client.go b/backend/internal/adapters/runtime/conpty/client.go index 0c882edd..628551a9 100644 --- a/backend/internal/adapters/runtime/conpty/client.go +++ b/backend/internal/adapters/runtime/conpty/client.go @@ -38,7 +38,7 @@ func clientSendMessage(addr, message string) error { if err != nil { return err } - defer conn.Close() + defer func() { _ = conn.Close() }() runes := []rune(message) for i := 0; i < len(runes); i += ptyInputChunkRunes { @@ -47,7 +47,10 @@ func clientSendMessage(addr, message string) error { end = len(runes) } chunk := string(runes[i:end]) - frame := EncodeMessage(MsgTerminalInput, []byte(chunk)) + frame, err := EncodeMessage(MsgTerminalInput, []byte(chunk)) + if err != nil { + return err + } if _, err := conn.Write(frame); err != nil { return err } @@ -59,7 +62,10 @@ func clientSendMessage(addr, message string) error { // Brief pause before Enter (matches TS: Enter sent as a separate frame). time.Sleep(ptyInputEnterDelay) - frame := EncodeMessage(MsgTerminalInput, []byte("\r")) + frame, err := EncodeMessage(MsgTerminalInput, []byte("\r")) + if err != nil { + return err + } _, err = conn.Write(frame) return err } @@ -72,12 +78,13 @@ func clientGetOutput(addr string, lines int) (string, error) { if err != nil { return "", nil // ponytail: connect failure -> "" like the TS } - defer conn.Close() + defer func() { _ = conn.Close() }() _ = conn.SetDeadline(time.Now().Add(getOutputTimeout)) req, _ := json.Marshal(GetOutputReq{Lines: lines}) - if _, err := conn.Write(EncodeMessage(MsgGetOutputReq, req)); err != nil { + reqFrame, _ := EncodeMessage(MsgGetOutputReq, req) // req is small JSON, never overflows uint32 + if _, err := conn.Write(reqFrame); err != nil { return "", nil } @@ -142,11 +149,12 @@ func clientIsAlive(addr string) (alive bool, transientErr error) { } return false, err } - defer conn.Close() + defer func() { _ = conn.Close() }() _ = conn.SetDeadline(time.Now().Add(isAliveTimeout)) - if _, err := conn.Write(EncodeMessage(MsgStatusReq, nil)); err != nil { + statusReqFrame, _ := EncodeMessage(MsgStatusReq, nil) // nil payload, never overflows + if _, err := conn.Write(statusReqFrame); err != nil { // We connected, then the write failed: connected-then-failed I/O is // transient (the host may still be up; the conn was disrupted). return false, err @@ -186,10 +194,7 @@ func clientIsAlive(addr string) (alive bool, transientErr error) { return result, nil default: // Connected but never got a STATUS_RES: read timeout or mid-read EOF. - // This is a connected-then-failed I/O error -> transient. - if lastErr == nil { - lastErr = errors.New("conpty: no status response before connection ended") - } + // lastErr is the error that broke the read loop (always non-nil here). return false, lastErr } } @@ -220,8 +225,9 @@ func clientKill(addr string) error { if err != nil { return nil // already dead } - defer conn.Close() + defer func() { _ = conn.Close() }() _ = conn.SetDeadline(time.Now().Add(isAliveTimeout)) - _, _ = conn.Write(EncodeMessage(MsgKillReq, nil)) + killFrame, _ := EncodeMessage(MsgKillReq, nil) // nil payload, never overflows + _, _ = conn.Write(killFrame) return nil } diff --git a/backend/internal/adapters/runtime/conpty/host.go b/backend/internal/adapters/runtime/conpty/host.go index e4261397..55c027e6 100644 --- a/backend/internal/adapters/runtime/conpty/host.go +++ b/backend/internal/adapters/runtime/conpty/host.go @@ -19,10 +19,10 @@ import ( // ptyConn is the host's handle to the running agent's pseudo-terminal. // The real impl (conptyConn) lives in host_conpty_windows.go; tests use a fake. type ptyConn interface { - io.Reader // PTY output (raw bytes from the terminal) - io.Writer // PTY input (keystrokes to the terminal) + io.Reader // PTY output (raw bytes from the terminal) + io.Writer // PTY input (keystrokes to the terminal) Resize(cols, rows int) error - Close() error // dispose the ConPTY + Close() error // dispose the ConPTY Done() <-chan struct{} // closed when the child process exits ExitCode() (int, bool) // (code, true) once exited; (0, false) while running PID() int @@ -43,8 +43,8 @@ type ServeConfig struct { // but stays alive (keep-alive, mirroring tmux behavior). Returns when shut down. func Serve(ctx context.Context, cfg ServeConfig) error { h := &host{ - cfg: cfg, - clients: make(map[net.Conn]struct{}), + cfg: cfg, + clients: make(map[net.Conn]struct{}), shutdownC: make(chan struct{}), } return h.run(ctx) @@ -52,9 +52,9 @@ func Serve(ctx context.Context, cfg ServeConfig) error { // host holds the mutable state for a single pty-host session. type host struct { - cfg ServeConfig - mu sync.Mutex - clients map[net.Conn]struct{} + cfg ServeConfig + mu sync.Mutex + clients map[net.Conn]struct{} shutdownOnce sync.Once shutdownC chan struct{} // closed when Shutdown is called @@ -74,12 +74,19 @@ func (h *host) run(ctx context.Context) error { } }() - // Accept loop. + // runAcceptLoop accepts connections until the listener closes. A listener + // close is normal (shutdown or external) and is treated as success. + h.runAcceptLoop() + return nil +} + +// runAcceptLoop runs the Accept loop until the listener closes or returns an +// error. Listener-close errors are swallowed; they signal normal shutdown. +func (h *host) runAcceptLoop() { for { conn, err := h.cfg.Listener.Accept() if err != nil { - // Listener closed: either shutdown or external close. - return nil + return } go h.handleConn(conn) } @@ -124,7 +131,9 @@ func (h *host) pumpPTY() { chunk := make([]byte, n) copy(chunk, buf[:n]) h.cfg.Ring.Append(chunk) - h.broadcast(EncodeMessage(MsgTerminalData, chunk)) + if frame, err := EncodeMessage(MsgTerminalData, chunk); err == nil { + h.broadcast(frame) + } } if err != nil { break @@ -181,7 +190,11 @@ func (h *host) handleConn(conn net.Conn) { h.mu.Lock() snap := h.cfg.Ring.Snapshot() if len(snap) > 0 { - if _, err := conn.Write(EncodeMessage(MsgTerminalData, snap)); err != nil { + snapFrame, err := EncodeMessage(MsgTerminalData, snap) + if err == nil { + _, err = conn.Write(snapFrame) + } + if err != nil { h.mu.Unlock() _ = conn.Close() return @@ -238,7 +251,9 @@ func (h *host) handleClientMsg(conn net.Conn, msgType byte, payload []byte) { lines = req.Lines } text := h.cfg.Ring.Tail(lines) - h.sendTo(conn, EncodeMessage(MsgGetOutputRes, []byte(text))) + if frame, err := EncodeMessage(MsgGetOutputRes, []byte(text)); err == nil { + h.sendTo(conn, frame) + } case MsgStatusReq: code, exited := h.cfg.PTY.ExitCode() @@ -260,5 +275,6 @@ func (h *host) handleClientMsg(conn net.Conn, msgType byte, payload []byte) { func statusFrame(alive bool, pid int, exitCode *int) []byte { sp := StatusPayload{Alive: alive, PID: pid, ExitCode: exitCode} b, _ := json.Marshal(sp) - return EncodeMessage(MsgStatusRes, b) + frame, _ := EncodeMessage(MsgStatusRes, b) // b is small JSON, never overflows uint32 + return frame } diff --git a/backend/internal/adapters/runtime/conpty/host_conpty_windows.go b/backend/internal/adapters/runtime/conpty/host_conpty_windows.go index 395b10ec..cc554726 100644 --- a/backend/internal/adapters/runtime/conpty/host_conpty_windows.go +++ b/backend/internal/adapters/runtime/conpty/host_conpty_windows.go @@ -13,8 +13,8 @@ import ( // conptyConn is the real ptyConn implementation backed by go-pty's ConPty // (Windows ConPTY API). Only compiled on Windows. type conptyConn struct { - pty gopty.ConPty - cmd *gopty.Cmd + pty gopty.ConPty + cmd *gopty.Cmd once sync.Once doneC chan struct{} @@ -87,8 +87,8 @@ func (c *conptyConn) Close() error { } return err } -func (c *conptyConn) Resize(cols, rows int) error { return c.pty.Resize(cols, rows) } -func (c *conptyConn) Done() <-chan struct{} { return c.doneC } +func (c *conptyConn) Resize(cols, rows int) error { return c.pty.Resize(cols, rows) } +func (c *conptyConn) Done() <-chan struct{} { return c.doneC } func (c *conptyConn) PID() int { if c.cmd.Process == nil { return 0 diff --git a/backend/internal/adapters/runtime/conpty/host_main.go b/backend/internal/adapters/runtime/conpty/host_main.go index 802f98d2..3e53b8db 100644 --- a/backend/internal/adapters/runtime/conpty/host_main.go +++ b/backend/internal/adapters/runtime/conpty/host_main.go @@ -41,7 +41,13 @@ func RunHost(args []string, stdout io.Writer) int { fmt.Fprintf(os.Stderr, "pty-host [%s]: listen: %v\n", sessionID, err) return 1 } - port := ln.Addr().(*net.TCPAddr).Port + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + _ = ln.Close() + fmt.Fprintf(os.Stderr, "pty-host [%s]: listener is not TCP\n", sessionID) + return 1 + } + port := tcpAddr.Port pty, err := newConPTY(cwd, shellCmd, shellArgs) if err != nil { @@ -51,7 +57,7 @@ func RunHost(args []string, stdout io.Writer) int { } // Print READY after both the listener and the PTY are up. - fmt.Fprintf(stdout, "READY:%d %d\n", pty.PID(), port) + _, _ = fmt.Fprintf(stdout, "READY:%d %d\n", pty.PID(), port) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/backend/internal/adapters/runtime/conpty/host_test.go b/backend/internal/adapters/runtime/conpty/host_test.go index 25aba26d..95dcf551 100644 --- a/backend/internal/adapters/runtime/conpty/host_test.go +++ b/backend/internal/adapters/runtime/conpty/host_test.go @@ -111,9 +111,12 @@ func (f *fakePTY) signalExit(code int) { // --------------------------------------------------------------------------- type testClient struct { - conn net.Conn - frameC chan struct{ typ byte; payload []byte } - parser *MessageParser + conn net.Conn + frameC chan struct { + typ byte + payload []byte + } + parser *MessageParser } func newTestClient(t *testing.T, addr string) *testClient { @@ -123,11 +126,17 @@ func newTestClient(t *testing.T, addr string) *testClient { t.Fatalf("dial %s: %v", addr, err) } tc := &testClient{ - conn: conn, - frameC: make(chan struct{ typ byte; payload []byte }, 64), + conn: conn, + frameC: make(chan struct { + typ byte + payload []byte + }, 64), } tc.parser = NewMessageParser(func(msgType byte, payload []byte) { - tc.frameC <- struct{ typ byte; payload []byte }{msgType, payload} + tc.frameC <- struct { + typ byte + payload []byte + }{msgType, payload} }) go func() { buf := make([]byte, 4096) @@ -162,7 +171,11 @@ func (tc *testClient) readFrame(t *testing.T) (typ byte, payload []byte) { // send writes a framed message to the server. func (tc *testClient) send(msgType byte, payload []byte) error { - _, err := tc.conn.Write(EncodeMessage(msgType, payload)) + frame, err := EncodeMessage(msgType, payload) + if err != nil { + return err + } + _, err = tc.conn.Write(frame) return err } @@ -272,10 +285,6 @@ func TestScrollbackLiveOrdering_NoDrop(t *testing.T) { // well as the live-broadcast boundary where the drop bug lived. const nChunks = 300 chunk := func(i int) []byte { return []byte(fmt.Sprintf("[%04d]\n", i)) } - var full []byte - for i := 0; i < nChunks; i++ { - full = append(full, chunk(i)...) - } // Emit continuously; each WriteOutput blocks until pumpPTY reads it. emitDone := make(chan struct{}) @@ -336,7 +345,7 @@ collect: if lines[len(lines)-1] == "" { lines = lines[:len(lines)-1] } - var prev int = -1 + prev := -1 for li, line := range lines { var idx int if _, err := fmt.Sscanf(line, "[%04d]", &idx); err != nil { diff --git a/backend/internal/adapters/runtime/conpty/proto.go b/backend/internal/adapters/runtime/conpty/proto.go index fa47df34..662f78e8 100644 --- a/backend/internal/adapters/runtime/conpty/proto.go +++ b/backend/internal/adapters/runtime/conpty/proto.go @@ -5,7 +5,11 @@ // Frame layout: [1-byte type][4-byte big-endian length][payload] package conpty -import "encoding/binary" +import ( + "encoding/binary" + "fmt" + "math" +) // Message type constants. Values must match pty-host.ts MSG_* constants exactly. const ( @@ -41,12 +45,18 @@ type GetOutputReq struct { // EncodeMessage encodes a single frame into the binary protocol format. // It allocates a fresh slice of exactly 5+len(payload) bytes. -func EncodeMessage(msgType byte, payload []byte) []byte { - frame := make([]byte, 5+len(payload)) +// Returns an error if the payload exceeds the 4-byte length field capacity. +func EncodeMessage(msgType byte, payload []byte) ([]byte, error) { + n := len(payload) + if n > math.MaxUint32 { + return nil, fmt.Errorf("conpty: payload too large (%d bytes, max %d)", n, math.MaxUint32) + } + payloadLen := uint32(n) // safe: n <= math.MaxUint32 checked above + frame := make([]byte, 5+n) frame[0] = msgType - binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload))) + binary.BigEndian.PutUint32(frame[1:5], payloadLen) copy(frame[5:], payload) - return frame + return frame, nil } // MessageParser is a streaming parser for the binary framing protocol. diff --git a/backend/internal/adapters/runtime/conpty/proto_test.go b/backend/internal/adapters/runtime/conpty/proto_test.go index 1116ec5f..1014f83a 100644 --- a/backend/internal/adapters/runtime/conpty/proto_test.go +++ b/backend/internal/adapters/runtime/conpty/proto_test.go @@ -9,7 +9,10 @@ import ( // TestEncodeMessage verifies the 5-byte header and payload are written correctly. func TestEncodeMessage(t *testing.T) { payload := []byte("hello") - frame := EncodeMessage(MsgTerminalData, payload) + frame, err := EncodeMessage(MsgTerminalData, payload) + if err != nil { + t.Fatalf("EncodeMessage: %v", err) + } if len(frame) != 5+len(payload) { t.Fatalf("frame len = %d, want %d", len(frame), 5+len(payload)) @@ -28,7 +31,10 @@ func TestEncodeMessage(t *testing.T) { // TestEncodeMessageZeroPayload verifies a zero-length payload encodes correctly. func TestEncodeMessageZeroPayload(t *testing.T) { - frame := EncodeMessage(MsgStatusReq, nil) + frame, err := EncodeMessage(MsgStatusReq, nil) + if err != nil { + t.Fatalf("EncodeMessage: %v", err) + } if len(frame) != 5 { t.Fatalf("frame len = %d, want 5", len(frame)) } @@ -57,7 +63,8 @@ func TestParserSingleFrame(t *testing.T) { var got []collected p := NewMessageParser(collect(&got)) - p.Feed(EncodeMessage(MsgTerminalData, []byte("hi"))) + f, _ := EncodeMessage(MsgTerminalData, []byte("hi")) + p.Feed(f) if len(got) != 1 { t.Fatalf("got %d messages, want 1", len(got)) @@ -75,8 +82,9 @@ func TestParserTwoFramesOneChunk(t *testing.T) { var got []collected p := NewMessageParser(collect(&got)) - chunk := append(EncodeMessage(MsgTerminalData, []byte("frame1")), - EncodeMessage(MsgTerminalInput, []byte("frame2"))...) + f1, _ := EncodeMessage(MsgTerminalData, []byte("frame1")) + f2, _ := EncodeMessage(MsgTerminalInput, []byte("frame2")) + chunk := append(f1, f2...) p.Feed(chunk) if len(got) != 2 { @@ -96,7 +104,7 @@ func TestParserByteAtATime(t *testing.T) { var got []collected p := NewMessageParser(collect(&got)) - frame := EncodeMessage(MsgResize, []byte(`{"cols":80,"rows":24}`)) + frame, _ := EncodeMessage(MsgResize, []byte(`{"cols":80,"rows":24}`)) for _, b := range frame { p.Feed([]byte{b}) } @@ -120,7 +128,8 @@ func TestParserInterleavedTypes(t *testing.T) { var chunk []byte for i, typ := range types { - chunk = append(chunk, EncodeMessage(typ, payloads[i])...) + f, _ := EncodeMessage(typ, payloads[i]) + chunk = append(chunk, f...) } var got []collected @@ -151,7 +160,7 @@ func TestParserPayloadIsCopy(t *testing.T) { p := NewMessageParser(collect(&got)) // Feed frame1 and capture the delivered payload pointer. - frame1 := EncodeMessage(MsgTerminalData, []byte("original")) + frame1, _ := EncodeMessage(MsgTerminalData, []byte("original")) p.Feed(frame1) if len(got) != 1 { t.Fatalf("after frame1: got %d messages, want 1", len(got)) @@ -160,7 +169,7 @@ func TestParserPayloadIsCopy(t *testing.T) { // Feed frame2 with the same payload length so the parser's internal buffer // overwrites the exact byte range that frame1 occupied. - frame2 := EncodeMessage(MsgTerminalInput, []byte("XXXXXXXX")) // same len as "original" + frame2, _ := EncodeMessage(MsgTerminalInput, []byte("XXXXXXXX")) // same len as "original" p.Feed(frame2) if len(got) != 2 { t.Fatalf("after frame2: got %d messages, want 2", len(got)) @@ -176,7 +185,8 @@ func TestParserPayloadIsCopy(t *testing.T) { func TestParserZeroLengthFrame(t *testing.T) { var got []collected p := NewMessageParser(collect(&got)) - p.Feed(EncodeMessage(MsgStatusReq, nil)) + f, _ := EncodeMessage(MsgStatusReq, nil) + p.Feed(f) if len(got) != 1 { t.Fatalf("got %d messages, want 1", len(got)) diff --git a/backend/internal/adapters/runtime/conpty/runtime.go b/backend/internal/adapters/runtime/conpty/runtime.go index efedbe8d..09c6d95d 100644 --- a/backend/internal/adapters/runtime/conpty/runtime.go +++ b/backend/internal/adapters/runtime/conpty/runtime.go @@ -199,19 +199,20 @@ func (r *Runtime) resolve(id string) *hostSession { return nil } for _, e := range entries { - if e.SessionID == id { - // Re-populate the map so subsequent calls skip the file scan. - recovered := &hostSession{addr: e.PipePath, pid: e.PtyHostPID} - r.mu.Lock() - // Only store if another goroutine hasn't beaten us. - if r.sessions[id] == nil { - r.sessions[id] = recovered - } else { - recovered = r.sessions[id] - } - r.mu.Unlock() - return recovered + if e.SessionID != id { + continue } + // Re-populate the map so subsequent calls skip the file scan. + recovered := &hostSession{addr: e.PipePath, pid: e.PtyHostPID} + r.mu.Lock() + // Only store if another goroutine hasn't beaten us. + if r.sessions[id] == nil { + r.sessions[id] = recovered + } else { + recovered = r.sessions[id] + } + r.mu.Unlock() + return recovered } return nil } diff --git a/backend/internal/adapters/runtime/conpty/runtime_test.go b/backend/internal/adapters/runtime/conpty/runtime_test.go index 9fc8bd96..efd647ae 100644 --- a/backend/internal/adapters/runtime/conpty/runtime_test.go +++ b/backend/internal/adapters/runtime/conpty/runtime_test.go @@ -664,13 +664,5 @@ func TestClientKill_Idempotent(t *testing.T) { } } -// helper for TestSendMessage_LargeMessageChunked -func min(a, b int) int { - if a < b { - return a - } - return b -} - // Ensure the packages compile (import check). var _ = io.Discard diff --git a/backend/internal/adapters/runtime/ptyexec/spawn_unix.go b/backend/internal/adapters/runtime/ptyexec/spawn_unix.go index 9dc6a2f9..52372630 100644 --- a/backend/internal/adapters/runtime/ptyexec/spawn_unix.go +++ b/backend/internal/adapters/runtime/ptyexec/spawn_unix.go @@ -15,8 +15,9 @@ import ( "syscall" "time" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/creack/pty" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) // Spawn starts argv on a real PTY via creack/pty, sized rows×cols from birth diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go index 7dfb1135..8388a571 100644 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -116,7 +116,7 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru return ports.RuntimeHandle{}, err } - launchCmd := buildLaunchCommand(cfg, r.shell) + launchCmd := buildLaunchCommand(cfg) args := newSessionArgs(id, cfg.WorkspacePath, r.shell, launchCmd) if _, err := r.run(ctx, args...); err != nil { return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: create session %s: %w", id, err) @@ -232,21 +232,21 @@ func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lin // local PTY, sized rows x cols from birth when known. ctx cancellation closes // the PTY. func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) { - argv, env, err := r.attachCommand(handle) + argv, err := r.attachCommand(handle) if err != nil { return nil, err } - return ptyexec.Spawn(ctx, argv, env, rows, cols) + return ptyexec.Spawn(ctx, argv, nil, rows, cols) } -// attachCommand returns the argv a human runs to attach their terminal to the -// session. tmux needs no per-session env block, so env is always nil. -func (r *Runtime) attachCommand(handle ports.RuntimeHandle) ([]string, []string, error) { +// attachCommand returns the argv to attach a terminal to the session. +// tmux needs no per-session env block. +func (r *Runtime) attachCommand(handle ports.RuntimeHandle) ([]string, error) { id, err := handleID(handle) if err != nil { - return nil, nil, err + return nil, err } - return []string{r.binary, "attach-session", "-t", id}, nil, nil + return []string{r.binary, "attach-session", "-t", id}, nil } // run wraps runner.Run with a per-call timeout context. @@ -436,13 +436,13 @@ func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } -// buildLaunchCommand builds the shell command string passed to `sh -c` (or the -// configured shell). It exports env vars, then runs argv, then execs a -// keep-alive interactive shell so the tmux session survives the agent exiting. +// buildLaunchCommand builds the shell command string passed to `sh -c`. It +// exports env vars, then runs argv, then execs a keep-alive interactive shell +// so the tmux session survives the agent exiting. // // PATH from cfg.Env is exported last, after all other keys, so an explicit // override takes effect. -func buildLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { +func buildLaunchCommand(cfg ports.RuntimeConfig) string { path := cfg.Env["PATH"] if path == "" { path = getenv("PATH") diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go index 254e3097..7a2851ee 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -40,7 +40,6 @@ func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...s return out, nil } - // -- helpers -- func newTestRuntime(chunkSize int) (*Runtime, *fakeRunner) { @@ -321,7 +320,6 @@ func (f *fakeRunnerSelectiveErr) Run(_ context.Context, env []string, name strin return out, nil } - // -- Destroy tests -- func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { @@ -535,7 +533,7 @@ func TestGetOutputArgs(t *testing.T) { func TestAttachCommandReturnsExpectedArgv(t *testing.T) { r := New(Options{Binary: "/usr/bin/tmux", Timeout: time.Second}) - argv, env, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1"}) + argv, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1"}) if err != nil { t.Fatalf("AttachCommand: %v", err) } @@ -543,14 +541,11 @@ func TestAttachCommandReturnsExpectedArgv(t *testing.T) { if !reflect.DeepEqual(argv, want) { t.Fatalf("argv = %#v, want %#v", argv, want) } - if env != nil { - t.Fatalf("env = %#v, want nil", env) - } } func TestAttachCommandRejectsInvalidHandle(t *testing.T) { r := New(Options{}) - _, _, err := r.attachCommand(ports.RuntimeHandle{ID: ""}) + _, err := r.attachCommand(ports.RuntimeHandle{ID: ""}) if err == nil { t.Fatal("AttachCommand empty handle: got nil, want error") } @@ -572,7 +567,7 @@ func TestCommandErrorUnwraps(t *testing.T) { // -- text helper tests -- func TestChunks(t *testing.T) { - if got := chunks("", 5); !reflect.DeepEqual(got, []string{""}){ + if got := chunks("", 5); !reflect.DeepEqual(got, []string{""}) { t.Fatalf("chunks empty = %#v", got) } if got := chunks("hello", 10); !reflect.DeepEqual(got, []string{"hello"}) { diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index c571b09a..058bdcf3 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -273,11 +273,11 @@ func (w *Workspace) StashUncommitted(ctx context.Context, info ports.WorkspaceIn return "", fmt.Errorf("gitworktree: reserve temp index path: %w", err) } tmpIdxPath := tmpIdx.Name() - tmpIdx.Close() + _ = tmpIdx.Close() // Remove now so git sees an absent path (not a 0-byte corrupt index). _ = os.Remove(tmpIdxPath) // Deferred remove is a best-effort cleanup in case git leaves the file. - defer os.Remove(tmpIdxPath) + defer func() { _ = os.Remove(tmpIdxPath) }() // Stage all tracked and non-ignored untracked files into the temp index. // GIT_INDEX_FILE overrides the index so the real index is never touched. diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_forcedestroy_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_forcedestroy_test.go index 693ba823..d2b20a4d 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace_forcedestroy_test.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace_forcedestroy_test.go @@ -15,8 +15,8 @@ import ( // file (which normal Destroy refuses via ErrWorkspaceDirty), then calls // ForceDestroy and asserts: // -// (a) the worktree path no longer exists on disk. -// (b) the worktree is deregistered from `git worktree list`. +// (a) the worktree path no longer exists on disk. +// (b) the worktree is deregistered from `git worktree list`. func TestWorkspaceIntegrationForceDestroyDirtyWorktree(t *testing.T) { git := requireGit(t) tmp := t.TempDir() diff --git a/backend/internal/cli/doctor_test.go b/backend/internal/cli/doctor_test.go index 2e4acf07..daf7c949 100644 --- a/backend/internal/cli/doctor_test.go +++ b/backend/internal/cli/doctor_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -53,6 +54,9 @@ func TestDoctorFailsWhenGitMissing(t *testing.T) { } func TestDoctorChecksTmuxVersion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("ao doctor emits a conpty check on Windows, not tmux") + } setConfigEnv(t) c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, args ...string) ([]byte, error) { switch name { @@ -78,6 +82,9 @@ func TestDoctorChecksTmuxVersion(t *testing.T) { // TestDoctorChecksTmuxVersionFailsOnError covers the case where tmux is found // but the version command fails. func TestDoctorChecksTmuxVersionFailsOnError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("ao doctor emits a conpty check on Windows, not tmux") + } setConfigEnv(t) c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { if name == "/bin/git" { @@ -93,6 +100,9 @@ func TestDoctorChecksTmuxVersionFailsOnError(t *testing.T) { } func TestDoctorWarnsWhenTmuxMissing(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("ao doctor emits a conpty check on Windows, not tmux") + } setConfigEnv(t) c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { return []byte("git version 2.43.0\n"), nil diff --git a/backend/internal/cli/ptyhost.go b/backend/internal/cli/ptyhost.go index d9265975..e7ca8481 100644 --- a/backend/internal/cli/ptyhost.go +++ b/backend/internal/cli/ptyhost.go @@ -14,9 +14,9 @@ import ( // consumed by cobra before being passed to RunHost. func newPtyHostCommand() *cobra.Command { return &cobra.Command{ - Use: "pty-host", - Short: "Run a ConPTY pty-host process (internal)", - Hidden: true, + Use: "pty-host", + Short: "Run a ConPTY pty-host process (internal)", + Hidden: true, DisableFlagParsing: true, RunE: func(_ *cobra.Command, args []string) error { code := conpty.RunHost(args, os.Stdout) diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 16246fd6..a3657f87 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -430,4 +430,3 @@ func TestWiring_SessionLifecycleInterfaceInvokedByDaemon(t *testing.T) { t.Fatal("SaveAndTeardownAll was not called through the interface") } } - diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index b90bf3c4..69950573 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -214,10 +214,10 @@ type missingAgents struct{} func (missingAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return nil, false } type fakeWorkspace struct { - createErr error - destroyErr error - destroyed int - lastCfg ports.WorkspaceConfig + createErr error + destroyErr error + destroyed int + lastCfg ports.WorkspaceConfig // path, when set, is returned as the workspace path so provisioning tests // can point at a real temp directory. path string From 9368a600998db275b0eb5eb452b81b749a44ee46 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 08:25:28 +0530 Subject: [PATCH 46/60] test(ci): set git identity in worktree clone fixture; loosen tmux reattach timeouts The preserve round-trip/conflict tests commit inside a worktree of the cloned repo, which had no git identity; CI runners cannot auto-derive one, failing with "empty ident name". Set user.email/user.name on the clone in setupOriginClone so its worktrees inherit it. The tmux reattach test drives a real shell and parses stty output, which is slow under -race on CI; raise its echo-write and SIZE-output waits. Co-Authored-By: Claude Opus 4.8 --- .../workspace/gitworktree/workspace_integration_test.go | 6 ++++++ backend/internal/terminal/attachment_integration_test.go | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go index 6c5f47d2..cc1f47c4 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go @@ -217,6 +217,12 @@ func setupOriginClone(t *testing.T, git, tmp string) string { runGit(t, git, seed, "remote", "add", "origin", origin) runGit(t, git, seed, "push", "-u", "origin", "main") run(t, git, "clone", origin, repo) + // A clone does not copy the seed's local identity, and CI runners have no + // global git identity to fall back on, so commit/commit-tree in this repo's + // worktrees would fail with "empty ident name". Set it on the clone; worktrees + // inherit the common dir config. + runGit(t, git, repo, "config", "user.email", "ao@example.com") + runGit(t, git, repo, "config", "user.name", "Ao Agents") runGit(t, git, repo, "checkout", "main") return repo } diff --git a/backend/internal/terminal/attachment_integration_test.go b/backend/internal/terminal/attachment_integration_test.go index 940a9199..1bef32a1 100644 --- a/backend/internal/terminal/attachment_integration_test.go +++ b/backend/internal/terminal/attachment_integration_test.go @@ -107,8 +107,11 @@ func TestAttachmentReattachAdoptsNewSize(t *testing.T) { t.Fatal("client B did not attach") } - eventually(t, 5*time.Second, func() bool { return b.write([]byte("echo SIZE:$(stty size)\n")) == nil }) - eventually(t, 10*time.Second, func() bool { + // Generous timeouts: this drives a real shell through tmux and parses its + // output, which is slow under -race on CI runners. The waits gate on the + // echo write succeeding, then on its SIZE output landing. + eventually(t, 15*time.Second, func() bool { return b.write([]byte("echo SIZE:$(stty size)\n")) == nil }) + eventually(t, 30*time.Second, func() bool { out := gotB.string() i := strings.LastIndex(out, "SIZE:") if i < 0 { From 9df0b2c7ae3cda921d884bc961ee46499268a4d6 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 08:37:25 +0530 Subject: [PATCH 47/60] test(terminal): resend size probe on tmux reattach until the shell answers Bumping timeouts was the wrong fix: a 30s wait still failed, so the probe output deterministically never appeared, not slowness. onOpen signals the stream accepts input, not that the reattached sh -i is at a prompt, so the first echo keystroke can be dropped. Resend the probe each poll until SIZE output lands, and on timeout dump the captured pane buffer so a remaining failure is self-explaining. Co-Authored-By: Claude Opus 4.8 --- .../terminal/attachment_integration_test.go | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/backend/internal/terminal/attachment_integration_test.go b/backend/internal/terminal/attachment_integration_test.go index 1bef32a1..7fac015b 100644 --- a/backend/internal/terminal/attachment_integration_test.go +++ b/backend/internal/terminal/attachment_integration_test.go @@ -107,24 +107,38 @@ func TestAttachmentReattachAdoptsNewSize(t *testing.T) { t.Fatal("client B did not attach") } - // Generous timeouts: this drives a real shell through tmux and parses its - // output, which is slow under -race on CI runners. The waits gate on the - // echo write succeeding, then on its SIZE output landing. - eventually(t, 15*time.Second, func() bool { return b.write([]byte("echo SIZE:$(stty size)\n")) == nil }) - eventually(t, 30*time.Second, func() bool { - out := gotB.string() - i := strings.LastIndex(out, "SIZE:") + // Drive the reattached shell until it reports its width. We RESEND the probe + // each iteration: onOpen means the stream accepts input, not that the inner + // `sh -i` is already at a prompt reading stdin after the reattach, so an early + // keystroke can be dropped; retrying covers that. Real tmux + shell output is + // also slow under -race on CI, hence the long deadline. On timeout we dump + // exactly what the pane produced so the failure is self-explaining (e.g. the + // probe echoed but never executed, or stty errored). + var captured string + gotWidth := false + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + _ = b.write([]byte("echo SIZE:$(stty size)\n")) + time.Sleep(250 * time.Millisecond) + captured = gotB.string() + i := strings.LastIndex(captured, "SIZE:") if i < 0 { - return false + continue } - fields := strings.Fields(strings.TrimPrefix(out[i:], "SIZE:")) + fields := strings.Fields(strings.TrimPrefix(captured[i:], "SIZE:")) if len(fields) < 2 { - return false + continue } cols, err := strconv.Atoi(strings.TrimFunc(fields[1], func(r rune) bool { return r < '0' || r > '9' })) if err != nil { - return false + continue } - return cols > 130 // B's 148 minus any tmux chrome; a stale A-layout reports <=115 - }) + if cols > 130 { // B's 148 minus any tmux chrome; a stale A-layout reports <=115 + gotWidth = true + break + } + } + if !gotWidth { + t.Fatalf("reattached pane never reported B's width (cols>130) within 30s; captured:\n%q", captured) + } } From 00b447ba8bb2f80220b47cfe2c24478bc39e7690 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 24 Jun 2026 08:52:59 +0530 Subject: [PATCH 48/60] test(terminal): set TERM for real-tmux attach tests so they run in CI Root cause (from the buffer dump the prior commit added): with TERM unset on CI runners, tmux refuses to attach a client and prints "open terminal failed: terminal does not support clear", so the pane never runs the size probe. The daemon defaults TERM in production; the tests bypass it. Set TERM=xterm-256color in both real-tmux tests. Reproduced locally with `env -u TERM` (fails the same way) and verified the fix passes under it. Co-Authored-By: Claude Opus 4.8 --- backend/internal/terminal/attachment_integration_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/internal/terminal/attachment_integration_test.go b/backend/internal/terminal/attachment_integration_test.go index 7fac015b..a21e87c8 100644 --- a/backend/internal/terminal/attachment_integration_test.go +++ b/backend/internal/terminal/attachment_integration_test.go @@ -23,6 +23,8 @@ func TestAttachmentStreamsRealTmuxPane(t *testing.T) { if _, err := exec.LookPath("tmux"); err != nil { t.Skip("tmux unavailable") } + // See TestAttachmentReattachAdoptsNewSize: tmux needs a usable TERM to attach. + t.Setenv("TERM", "xterm-256color") name := "ao-term-it-" + strconv.Itoa(os.Getpid()) rt := tmux.New(tmux.Options{Timeout: 10 * time.Second}) @@ -61,6 +63,11 @@ func TestAttachmentReattachAdoptsNewSize(t *testing.T) { if _, err := exec.LookPath("tmux"); err != nil { t.Skip("tmux unavailable") } + // tmux refuses to attach a client without a usable TERM, printing + // "open terminal failed: terminal does not support clear". The daemon sets a + // default TERM in production (Finder-launched attach fix); CI runners have + // none, so set it here to match the real environment. + t.Setenv("TERM", "xterm-256color") name := "ao-term-size-it-" + strconv.Itoa(os.Getpid()) rt := tmux.New(tmux.Options{Timeout: 10 * time.Second}) From 20daf2920d3c96f8ef0f1f9c6bdaaf8109e7205d Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 09:42:13 +0530 Subject: [PATCH 49/60] docs(spec): crash-proof session reconcile design Boot-time reconcile makes live tmux + worktree state match the DB on every daemon start, so a SIGKILL/crash/force-quit that skips SaveAndTeardownAll no longer leaks an orphaned daemon, tmux sessions, or worktrees. Adopt crash-surviving tmux sessions, preserve-and-terminate dead ones, reap in-namespace orphans, and add a frontend kill+replace branch for a wedged orphan daemon. Co-Authored-By: Claude Opus 4.8 --- ...24-crash-proof-session-reconcile-design.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-crash-proof-session-reconcile-design.md diff --git a/docs/superpowers/specs/2026-06-24-crash-proof-session-reconcile-design.md b/docs/superpowers/specs/2026-06-24-crash-proof-session-reconcile-design.md new file mode 100644 index 00000000..fce3980c --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-crash-proof-session-reconcile-design.md @@ -0,0 +1,146 @@ +# Crash-proof session reconcile — design + +Date: 2026-06-24 +Status: approved (brainstorming), pending implementation plan +Branch: `feat/crash-proof-session-reconcile` + +## Problem + +Closing the app can leave orphaned state behind: a detached daemon still +holding its port, live tmux sessions, and worktrees on disk. Observed +directly: app closed, `running.json` pointed at a dead PID, two tmux sessions +(`ao-agents-11`, the orchestrator `ao-agents-12`) still alive, and three +worktrees on disk. + +### Root cause + +`SaveAndTeardownAll` (the save-on-close teardown) is gated entirely behind +`srv.Run` returning (`backend/internal/daemon/daemon.go:151,163`). `srv.Run` +only returns on a catchable signal (`signal.NotifyContext` for SIGINT/SIGTERM) +or `POST /shutdown`. A **SIGKILL, a crash, or the AppTranslocation mount +vanishing** satisfies none of these: `srv.Run` never returns, so teardown +never runs. The DB confirmed it for the incident: sessions 11 and 12 were +still `is_terminated=0` with no termination or marker writes after the last +activity. + +The daemon is spawned `detached` (`frontend/src/main.ts:509`), so on a +non-clean app exit it is orphaned (reparented to launchd), keeps holding the +port and its tmux sessions, and later dies by SIGKILL without ever tearing +down. + +### Key principle + +You cannot guarantee a clean shutdown. Any fix that only hardens the shutdown +path leaves the SIGKILL/crash hole open. Correctness must come from +**idempotent boot-time reconcile**: every daemon start makes live reality +(tmux + worktrees) match the DB, regardless of how the previous run ended. + +## Scope + +In scope: a no-leak guarantee. After any app exit (clean, force-quit, crash), +the next boot reconciles so there are no orphaned daemon/tmux/worktrees, and +every live session is either adopted or cleanly terminated. + +Out of scope (deliberately unchanged — separate decision): + +- Orchestrator re-spawn-vs-restore policy and stale `session_worktrees` marker + cleanup (the "orchestrator spam" bug). +- Auto-relaunching crash-killed agents. Reconcile preserves work and marks + terminated; it never spawns a new agent. + +## Design + +### Component 1 — `Manager.Reconcile(ctx)` (daemon side, the core) + +A single idempotent pass that **replaces** the bare `RestoreAll` call at +`daemon.go:147`, run before the server starts serving. It folds the existing +restore logic in as one branch. Iterating `ListAllSessions`: + +| DB state | tmux via `IsAlive(handle)` | Action | +| --------------------------------- | -------------------------- | ------------------------------------------------------------------ | +| `is_terminated=0` | alive | **Adopt** — no-op, leave live. Agent keeps running. | +| `is_terminated=0` | gone | `StashUncommitted` (best-effort) -> `MarkTerminated`. No relaunch. | +| `is_terminated=1`, has marker | (n/a) | Existing `RestoreAll` restore branch, unchanged. | +| `is_terminated=1`, no marker | (n/a) | Leave terminated (user-killed before shutdown; untouched). | + +Adoption is safe and lossless because tmux is the persistence layer: the +detached tmux session survives a daemon crash, and the session's +`runtime_handle_id` (the tmux session name) is in the DB. A matching live +handle means the session genuinely survived; adopting is a no-op. + +After the per-session pass, **orphan-reap** using a new `Runtime.ListSessions`. +The reap is **scoped to this daemon's session-id namespace** (the project +session-id prefix, e.g. `ao-agents-`): a tmux name outside that namespace is +never touched, which is what keeps a co-resident AO install's sessions +(observed: `aa-107`, `aa-109` from a different install on the same host) safe. +Within the namespace, for every live tmux session whose name maps to **no +`is_terminated=0` DB row** (a terminated row, or no row at all) -> `Destroy` +it. + +Worktrees: a terminated session's worktree is pruned **only if it is clean and +registered-but-dead**; **dirty worktrees are always preserved** (this is why an +intentionally-preserved dirty worktree like session 9 survives — correct, by +design; matches the interactive `Destroy` `ErrWorkspaceDirty` refusal). + +### Component 2 — `Runtime.ListSessions(ctx) ([]string, error)` (port addition) + +The only new surface on the `ports.Runtime` interface +(`backend/internal/ports/outbound.go`). + +- tmux: `tmux list-sessions -F '#{session_name}'`. An empty list (no server / + no sessions) is returned as an empty slice and **no error**, mirroring the + existing `has-session` missing-output handling. +- conpty: return an empty slice (no persistent enumeration model). Reconcile's + orphan-reap is therefore a tmux-only effect, which is correct: only tmux + sessions outlive the daemon. + +### Component 3 — Frontend "replace wedged orphan" branch + +The healthy-attach path already exists: `inspectExistingDaemon` + +`resolveDaemonFromPort` (`frontend/src/main.ts:457-485`) attach to a healthy +existing daemon. The gap is the failure branch. Add: when the port is held but +the daemon is unhealthy / identity-mismatched / PID-dead-but-port-held, +SIGTERM the process group, wait for the port to free, clear the stale +`running.json`, then spawn fresh (which runs Reconcile). A healthy orphan is +reconnected exactly as today, untouched. + +## Behaviour for the observed incident + +- 11 & 12 (alive tmux) -> **adopted**, nothing lost. +- A future crash where tmux also died -> work stashed, marked terminated, no + orphan left. +- Orphan daemon on next launch -> reused if healthy, else killed + replaced. +- Orphan tmux with no live owner -> reaped. +- Dirty worktrees (like 9) -> preserved. + +## Error handling + +- Per-session reconcile failures are logged and never abort the pass (same + pattern as `SaveAndTeardownAll` / `RestoreAll`). +- `Reconcile` is best-effort and must never block boot: a failure is logged, + boot continues (same contract as the current `RestoreAll` call site). +- `StashUncommitted` on a crash-dead worktree is best-effort; a failure logs + and still proceeds to `MarkTerminated` (no work is destroyed — the worktree + stays on disk). +- Orphan-reap `Destroy` failures are logged and do not abort the loop. + +## Testing + +- Unit: table-test `Reconcile` over each matrix row with a fake runtime + (alive / gone / orphan), asserting DB transitions and runtime `Destroy` + calls. +- Unit: `ListSessions` argument building and missing-output parsing, mirroring + the existing tmux `has-session` tests. +- Integration: extend the sqlite lifecycle test with a seeded + `is_terminated=0`-but-dead session plus an orphan tmux name; assert the + post-reconcile DB state and the kill calls. + +## Open question flagged for review + +Orphan-reap namespace scoping: the design reaps in-namespace tmux names that +have no live DB owner, including names with no DB row at all. The namespace +prefix (e.g. `ao-agents-`) is what protects co-resident installs. Confirm the +prefix is derivable reliably at reconcile time (from the project's session +prefix). If the no-DB-row case ever proves too broad, the safe fallback is to +reap only names resolving to a known-but-terminated DB row and merely log +in-namespace names with no row. From 5801dc01ce6ba17b1906be7372b6864406a4c229 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 10:01:07 +0530 Subject: [PATCH 50/60] docs(spec): simplify reconcile to per-session IsAlive, drop ListSessions Every leak in the incident maps to a DB row, so orphan-reap is a per-session IsAlive+Destroy over terminated rows; no runtime enumeration, no ports/conpty/ runtimeselect changes. Reaping a tmux session with no DB row is deferred (YAGNI). Co-Authored-By: Claude Opus 4.8 --- ...24-crash-proof-session-reconcile-design.md | 92 ++++++++++--------- 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/docs/superpowers/specs/2026-06-24-crash-proof-session-reconcile-design.md b/docs/superpowers/specs/2026-06-24-crash-proof-session-reconcile-design.md index fce3980c..2c0c41fd 100644 --- a/docs/superpowers/specs/2026-06-24-crash-proof-session-reconcile-design.md +++ b/docs/superpowers/specs/2026-06-24-crash-proof-session-reconcile-design.md @@ -56,43 +56,51 @@ A single idempotent pass that **replaces** the bare `RestoreAll` call at `daemon.go:147`, run before the server starts serving. It folds the existing restore logic in as one branch. Iterating `ListAllSessions`: -| DB state | tmux via `IsAlive(handle)` | Action | -| --------------------------------- | -------------------------- | ------------------------------------------------------------------ | -| `is_terminated=0` | alive | **Adopt** — no-op, leave live. Agent keeps running. | -| `is_terminated=0` | gone | `StashUncommitted` (best-effort) -> `MarkTerminated`. No relaunch. | -| `is_terminated=1`, has marker | (n/a) | Existing `RestoreAll` restore branch, unchanged. | -| `is_terminated=1`, no marker | (n/a) | Leave terminated (user-killed before shutdown; untouched). | +Reconcile iterates `ListAllSessions` and acts per session: + +| DB state | tmux via `IsAlive(handle)` | Action | +| ----------------------------- | -------------------------- | ------------------------------------------------------------------ | +| `is_terminated=0` | alive | **Adopt** — no-op, leave live. Agent keeps running. | +| `is_terminated=0` | gone | `StashUncommitted` (best-effort) -> `MarkTerminated`. No relaunch. | +| `is_terminated=1` | alive | **Reap** — `Destroy` the leaked tmux session. | +| `is_terminated=1`, has marker | gone | Existing `RestoreAll` restore branch, unchanged. | +| `is_terminated=1`, no marker | gone | Leave terminated (user-killed before shutdown; untouched). | Adoption is safe and lossless because tmux is the persistence layer: the detached tmux session survives a daemon crash, and the session's `runtime_handle_id` (the tmux session name) is in the DB. A matching live handle means the session genuinely survived; adopting is a no-op. -After the per-session pass, **orphan-reap** using a new `Runtime.ListSessions`. -The reap is **scoped to this daemon's session-id namespace** (the project -session-id prefix, e.g. `ao-agents-`): a tmux name outside that namespace is -never touched, which is what keeps a co-resident AO install's sessions -(observed: `aa-107`, `aa-109` from a different install on the same host) safe. -Within the namespace, for every live tmux session whose name maps to **no -`is_terminated=0` DB row** (a terminated row, or no row at all) -> `Destroy` -it. - -Worktrees: a terminated session's worktree is pruned **only if it is clean and -registered-but-dead**; **dirty worktrees are always preserved** (this is why an +The **reap** of a terminated-but-still-alive tmux session uses the existing +per-handle `IsAlive` + `Destroy`; no session enumeration is needed because +every leak tied to a session has a DB row (confirmed for the incident: 9, 11, +12 all have rows). Reap must run **before** the restore branch so a restored +session gets a fresh runtime rather than colliding with a leaked tmux of the +same name. + +Worktrees: dirty worktrees are **always preserved** (this is why an intentionally-preserved dirty worktree like session 9 survives — correct, by design; matches the interactive `Destroy` `ErrWorkspaceDirty` refusal). +Reconcile does not delete worktree directories; worktree lifecycle stays with +the existing teardown/restore/cleanup paths. + +### Component 2 — order of operations -### Component 2 — `Runtime.ListSessions(ctx) ([]string, error)` (port addition) +`Reconcile` runs three phases over `ListAllSessions`, in order: -The only new surface on the `ports.Runtime` interface -(`backend/internal/ports/outbound.go`). +1. **Live pass** (`is_terminated=0`): adopt if `IsAlive`, else stash + + `MarkTerminated`. +2. **Reap pass** (`is_terminated=1` with live tmux): `Destroy` the leaked + session. +3. **Restore pass**: the existing `RestoreAll` body (terminated + marked + sessions), unchanged. -- tmux: `tmux list-sessions -F '#{session_name}'`. An empty list (no server / - no sessions) is returned as an empty slice and **no error**, mirroring the - existing `has-session` missing-output handling. -- conpty: return an empty slice (no persistent enumeration model). Reconcile's - orphan-reap is therefore a tmux-only effect, which is correct: only tmux - sessions outlive the daemon. +Deferred (YAGNI): reaping a tmux session that has **no DB row at all** (a true +orphan). Not observed in the incident and not reachable through normal spawn +(every tmux session is created for a DB-backed session). If it ever appears, it +is a follow-up that adds a `Runtime.ListSessions` enumerator scoped to this +daemon's session-id namespace (so a co-resident AO install's sessions — +observed: `aa-107`, `aa-109` — stay untouched). Out of scope here. ### Component 3 — Frontend "replace wedged orphan" branch @@ -110,7 +118,7 @@ reconnected exactly as today, untouched. - A future crash where tmux also died -> work stashed, marked terminated, no orphan left. - Orphan daemon on next launch -> reused if healthy, else killed + replaced. -- Orphan tmux with no live owner -> reaped. +- A terminated session whose tmux survived teardown -> reaped (`Destroy`). - Dirty worktrees (like 9) -> preserved. ## Error handling @@ -126,21 +134,19 @@ reconnected exactly as today, untouched. ## Testing -- Unit: table-test `Reconcile` over each matrix row with a fake runtime - (alive / gone / orphan), asserting DB transitions and runtime `Destroy` +- Unit: table-test `Reconcile` over each matrix row with a fake runtime whose + `IsAlive` is scriptable per handle (alive / gone), asserting DB transitions + (`MarkTerminated`), `StashUncommitted` calls, and runtime `Destroy` (reap) calls. -- Unit: `ListSessions` argument building and missing-output parsing, mirroring - the existing tmux `has-session` tests. +- Unit: assert the live pass adopts (no `Destroy`, no `MarkTerminated`) when + `IsAlive` is true. - Integration: extend the sqlite lifecycle test with a seeded - `is_terminated=0`-but-dead session plus an orphan tmux name; assert the - post-reconcile DB state and the kill calls. - -## Open question flagged for review - -Orphan-reap namespace scoping: the design reaps in-namespace tmux names that -have no live DB owner, including names with no DB row at all. The namespace -prefix (e.g. `ao-agents-`) is what protects co-resident installs. Confirm the -prefix is derivable reliably at reconcile time (from the project's session -prefix). If the no-DB-row case ever proves too broad, the safe fallback is to -reap only names resolving to a known-but-terminated DB row and merely log -in-namespace names with no row. + `is_terminated=0`-but-dead session and a `is_terminated=1`-but-alive session; + assert the post-reconcile DB state and the reap `Destroy` call. + +## Open question (resolved during planning) + +Orphan-reap is done per-session via `IsAlive` over DB rows, so there is no +enumeration and no namespace-matching risk in this iteration. The riskier +"reap a tmux session with no DB row" case is deferred (see Component 2, +Deferred), which removes the original namespace-scoping question from scope. From 24720a11b53ed45e6ac2caa0ea4f12e59124fd89 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 10:03:24 +0530 Subject: [PATCH 51/60] docs(plan): crash-proof session reconcile implementation plan Co-Authored-By: Claude Opus 4.8 --- ...026-06-24-crash-proof-session-reconcile.md | 628 ++++++++++++++++++ 1 file changed, 628 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-24-crash-proof-session-reconcile.md diff --git a/docs/superpowers/plans/2026-06-24-crash-proof-session-reconcile.md b/docs/superpowers/plans/2026-06-24-crash-proof-session-reconcile.md new file mode 100644 index 00000000..1ee9bc3b --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-crash-proof-session-reconcile.md @@ -0,0 +1,628 @@ +# Crash-proof Session Reconcile Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On every daemon boot, reconcile live tmux + DB state so a SIGKILL/crash/force-quit that skips `SaveAndTeardownAll` no longer leaks an orphaned daemon, tmux sessions, or worktrees. + +**Architecture:** Add `Manager.Reconcile(ctx)` to the session manager: a live pass (adopt alive sessions, stash+terminate dead ones), a reap pass (`Destroy` tmux of terminated sessions whose pane survived), then the existing `RestoreAll` body. Wire it in place of the bare `RestoreAll` call at daemon boot. On the frontend, add a kill+replace branch for a wedged orphan daemon on launch. tmux is the persistence layer, so adopting a crash-surviving session is a no-op. + +**Tech Stack:** Go 1.x (backend, `go test`), TypeScript/Electron (frontend, `npm test` in `frontend/`). tmux runtime adapter. SQLite store. + +## Global Constraints + +- No em dashes (`—`) or en dashes (`–`) anywhere: prose, code comments, commit messages. Use a period, comma, colon, semicolon, or parentheses. +- Go: run `gofmt`/`goimports`; keep `golangci-lint` clean (the repo's CI gates on it). +- Git author email: `dev@theharshitsingh.com`. Commit with `git -c user.email=dev@theharshitsingh.com commit ...`. +- TDD: write the failing test first, watch it fail, implement minimally, watch it pass, commit. +- Reconcile is best-effort: per-session failures log and never abort the pass or block boot (same contract as the existing `RestoreAll` call site at `backend/internal/daemon/daemon.go:147`). +- Reconcile never deletes worktree directories and never spawns a new agent. Dirty worktrees are always preserved. + +--- + +## File Structure + +- `backend/internal/session_manager/manager.go` — add `Reconcile`, `reconcileLive`, `reconcileReap` methods; widen the `runtimeController` interface with `IsAlive`. The existing `RestoreAll` body is reused (called as the restore phase). +- `backend/internal/session_manager/manager_test.go` — add `IsAlive` to `fakeRuntime` (scriptable per handle); add `Reconcile` unit tests. +- `backend/internal/daemon/lifecycle_wiring.go` — add `Reconcile` to the `sessionLifecycle` interface. +- `backend/internal/daemon/daemon.go` — replace the `RestoreAll(ctx)` boot call with `Reconcile(ctx)`. +- `backend/internal/daemon/wiring_test.go` — update the `sessionLifecycle` fake/mock if it asserts the interface. +- `backend/internal/integration/lifecycle_sqlite_test.go` — add a reconcile integration case. +- `frontend/src/main.ts` — add the wedged-orphan kill+replace branch in `startDaemonInner`. +- `frontend/src/main.test.ts` (or the existing main-process test file) — test the kill+replace decision. + +--- + +## Task 1: Widen `runtimeController` with `IsAlive` and adopt-alive live pass + +**Files:** +- Modify: `backend/internal/session_manager/manager.go:64-67` (interface), add methods near `manager.go:558-623` +- Test: `backend/internal/session_manager/manager_test.go:138-152` (fake), new test fn + +**Interfaces:** +- Consumes: `domain.SessionRecord` (`.IsTerminated`, `.Metadata.WorkspacePath`, `.Metadata.Branch`, `.Metadata.RuntimeHandleID`); `runtimeHandle(meta)` -> `ports.RuntimeHandle`; `workspaceInfo(rec)` -> `ports.WorkspaceInfo`; `m.workspace.StashUncommitted`, `m.lcm.MarkTerminated`, `m.store.ListAllSessions`. +- Produces: `func (m *Manager) reconcileLive(ctx context.Context, rec domain.SessionRecord) error`; widened `runtimeController` with `IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error)`. + +- [ ] **Step 1: Add `IsAlive` to the test `fakeRuntime`** + +In `manager_test.go`, extend the fake so `IsAlive` is scriptable per handle id and records calls: + +```go +type fakeRuntime struct { + createErr error + created, destroyed int + lastCfg ports.RuntimeConfig + // aliveByHandle maps a RuntimeHandle.ID to its liveness; missing = false. + aliveByHandle map[string]bool + aliveErr error + destroyedIDs []string +} + +func (r *fakeRuntime) IsAlive(_ context.Context, handle ports.RuntimeHandle) (bool, error) { + if r.aliveErr != nil { + return false, r.aliveErr + } + return r.aliveByHandle[handle.ID], nil +} +``` + +Also record the destroyed handle id in the existing `Destroy`: + +```go +func (r *fakeRuntime) Destroy(_ context.Context, handle ports.RuntimeHandle) error { + r.destroyed++ + r.destroyedIDs = append(r.destroyedIDs, handle.ID) + return nil +} +``` + +- [ ] **Step 2: Write the failing test for the live pass** + +Add to `manager_test.go`. A live (`is_terminated=0`) session whose tmux is GONE must be stashed and marked terminated; an ALIVE one must be left untouched. + +```go +func TestReconcileLive_DeadSessionStashedAndTerminated(t *testing.T) { + st := newFakeStore() + rt := &fakeRuntime{aliveByHandle: map[string]bool{}} // handle not alive + ws := &fakeWorkspace{stashRef: "refs/ao/preserved/s1"} + lcm := &fakeLCM{} + m := newManager(t, st, rt, ws, lcm) + + rec := domain.SessionRecord{ + ID: "s1", + ProjectID: "p1", + IsTerminated: false, + Metadata: domain.SessionMetadata{ + Branch: "ao/s1/root", WorkspacePath: "/wt/s1", RuntimeHandleID: "s1", + }, + } + + if err := m.reconcileLive(context.Background(), rec); err != nil { + t.Fatalf("reconcileLive: %v", err) + } + if ws.stashCalls != 1 { + t.Fatalf("StashUncommitted calls = %d, want 1", ws.stashCalls) + } + if lcm.terminated["s1"] != 1 { + t.Fatalf("MarkTerminated(s1) = %d, want 1", lcm.terminated["s1"]) + } + if rt.destroyed != 0 { + t.Fatalf("Destroy calls = %d, want 0 (dead session: no tmux to kill)", rt.destroyed) + } +} + +func TestReconcileLive_AliveSessionAdoptedNoop(t *testing.T) { + st := newFakeStore() + rt := &fakeRuntime{aliveByHandle: map[string]bool{"s2": true}} + ws := &fakeWorkspace{} + lcm := &fakeLCM{} + m := newManager(t, st, rt, ws, lcm) + + rec := domain.SessionRecord{ + ID: "s2", ProjectID: "p1", IsTerminated: false, + Metadata: domain.SessionMetadata{Branch: "ao/s2/root", WorkspacePath: "/wt/s2", RuntimeHandleID: "s2"}, + } + + if err := m.reconcileLive(context.Background(), rec); err != nil { + t.Fatalf("reconcileLive: %v", err) + } + if ws.stashCalls != 0 || lcm.terminated["s2"] != 0 || rt.destroyed != 0 { + t.Fatalf("adopt should be a no-op: stash=%d term=%d destroy=%d", ws.stashCalls, lcm.terminated["s2"], rt.destroyed) + } +} +``` + +> If `fakeWorkspace` lacks a `stashCalls` counter or `fakeLCM` lacks a `terminated` map, add them: increment `stashCalls` inside `fakeWorkspace.StashUncommitted`, and `l.terminated[id]++` (init the map in the fake) inside `fakeLCM.MarkTerminated`. If `newManager` has a different signature in this file, match the existing constructor used by other tests rather than inventing one. + +- [ ] **Step 3: Run the test, verify it fails** + +Run: `cd backend && go test ./internal/session_manager/ -run TestReconcileLive -v` +Expected: FAIL — `m.reconcileLive` undefined, and `IsAlive` not in `runtimeController`. + +- [ ] **Step 4: Widen the interface and implement `reconcileLive`** + +In `manager.go`, widen the interface (around line 64): + +```go +type runtimeController interface { + Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) + Destroy(ctx context.Context, handle ports.RuntimeHandle) error + // IsAlive reports whether the handle's runtime session still exists. Used by + // Reconcile on boot to adopt crash-surviving sessions and reap leaked ones. + IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) +} +``` + +Add the method (place it near `saveAndTeardownOne`, around line 623): + +```go +// reconcileLive handles a single non-terminated session on boot. If its runtime +// session is still alive (tmux is the persistence layer, so it survives a daemon +// crash) we adopt it: a no-op, the agent keeps running. If the runtime is gone, +// the agent died with the daemon, so we capture any uncommitted work into a +// preserve ref (best-effort) and mark the session terminated. We never relaunch +// here (that is spawn policy) and never delete the worktree. +func (m *Manager) reconcileLive(ctx context.Context, rec domain.SessionRecord) error { + if rec.Metadata.WorkspacePath == "" || rec.Metadata.Branch == "" { + return nil + } + handle := runtimeHandle(rec.Metadata) + if handle.ID != "" { + alive, err := m.runtime.IsAlive(ctx, handle) + if err != nil { + // A failed probe is not proof of death: leave the session as-is. + return fmt.Errorf("reconcile %s: probe: %w", rec.ID, err) + } + if alive { + return nil // adopt: the session survived the crash. + } + } + // Runtime is gone: preserve work (best-effort) then mark terminated. + if _, err := m.workspace.StashUncommitted(ctx, workspaceInfo(rec)); err != nil { + m.logger.Warn("reconcile: stash uncommitted failed; marking terminated anyway", "sessionID", rec.ID, "error", err) + } + if err := m.lcm.MarkTerminated(ctx, rec.ID); err != nil { + return fmt.Errorf("reconcile %s: mark terminated: %w", rec.ID, err) + } + return nil +} +``` + +- [ ] **Step 5: Run the tests, verify they pass** + +Run: `cd backend && go test ./internal/session_manager/ -run TestReconcileLive -v` +Expected: PASS (both cases). + +- [ ] **Step 6: Build to confirm the widened interface still satisfies the concrete runtime** + +Run: `cd backend && go build ./...` +Expected: success (the concrete `runtimeselect.Runtime`/`tmux.Runtime` already implement `IsAlive`). + +- [ ] **Step 7: Commit** + +```bash +cd backend && gofmt -w internal/session_manager/manager.go internal/session_manager/manager_test.go +git add internal/session_manager/manager.go internal/session_manager/manager_test.go +git -c user.email=dev@theharshitsingh.com commit -m "feat(session): reconcile live pass (adopt alive, stash+terminate dead)" +``` + +--- + +## Task 2: Reap pass and the `Reconcile` entry point + +**Files:** +- Modify: `backend/internal/session_manager/manager.go` (add `reconcileReap`, `Reconcile`; the latter reuses the existing `RestoreAll` body) +- Test: `backend/internal/session_manager/manager_test.go` + +**Interfaces:** +- Consumes: `m.store.ListAllSessions`, `m.runtime.IsAlive`, `m.runtime.Destroy`, `reconcileLive` (Task 1), the existing `RestoreAll` method (`manager.go:637`). +- Produces: `func (m *Manager) Reconcile(ctx context.Context) error`; `func (m *Manager) reconcileReap(ctx context.Context, rec domain.SessionRecord) error`. + +- [ ] **Step 1: Write the failing reap test** + +Add to `manager_test.go`. A terminated session whose tmux is still alive must have its tmux `Destroy`d; a terminated session whose tmux is gone must not. + +```go +func TestReconcileReap_TerminatedButAliveTmuxDestroyed(t *testing.T) { + st := newFakeStore() + rt := &fakeRuntime{aliveByHandle: map[string]bool{"t1": true}} + ws := &fakeWorkspace{} + lcm := &fakeLCM{} + m := newManager(t, st, rt, ws, lcm) + + rec := domain.SessionRecord{ + ID: "t1", ProjectID: "p1", IsTerminated: true, + Metadata: domain.SessionMetadata{RuntimeHandleID: "t1"}, + } + + if err := m.reconcileReap(context.Background(), rec); err != nil { + t.Fatalf("reconcileReap: %v", err) + } + if len(rt.destroyedIDs) != 1 || rt.destroyedIDs[0] != "t1" { + t.Fatalf("destroyedIDs = %v, want [t1]", rt.destroyedIDs) + } +} + +func TestReconcileReap_TerminatedAndDeadTmuxLeftAlone(t *testing.T) { + st := newFakeStore() + rt := &fakeRuntime{aliveByHandle: map[string]bool{}} // t2 not alive + m := newManager(t, st, rt, &fakeWorkspace{}, &fakeLCM{}) + + rec := domain.SessionRecord{ + ID: "t2", ProjectID: "p1", IsTerminated: true, + Metadata: domain.SessionMetadata{RuntimeHandleID: "t2"}, + } + if err := m.reconcileReap(context.Background(), rec); err != nil { + t.Fatalf("reconcileReap: %v", err) + } + if rt.destroyed != 0 { + t.Fatalf("Destroy calls = %d, want 0", rt.destroyed) + } +} +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `cd backend && go test ./internal/session_manager/ -run TestReconcileReap -v` +Expected: FAIL — `m.reconcileReap` undefined. + +- [ ] **Step 3: Implement `reconcileReap` and `Reconcile`** + +Add `reconcileReap` near `reconcileLive`: + +```go +// reconcileReap kills the leaked tmux session of a session the DB already marks +// terminated. This covers the teardown that marked the row terminated but failed +// to kill the runtime (e.g. ForceDestroy/Destroy errored after MarkTerminated). +// Destroy is idempotent, so an already-gone session is a no-op. +func (m *Manager) reconcileReap(ctx context.Context, rec domain.SessionRecord) error { + handle := runtimeHandle(rec.Metadata) + if handle.ID == "" { + return nil + } + alive, err := m.runtime.IsAlive(ctx, handle) + if err != nil { + return fmt.Errorf("reconcile reap %s: probe: %w", rec.ID, err) + } + if !alive { + return nil + } + if err := m.runtime.Destroy(ctx, handle); err != nil { + return fmt.Errorf("reconcile reap %s: destroy: %w", rec.ID, err) + } + return nil +} +``` + +Add the entry point. Place it just above `RestoreAll` (manager.go:625) and have it call the existing `RestoreAll` as the restore phase: + +```go +// Reconcile is the boot-time consistency pass. It replaces the bare RestoreAll +// call so that however the previous daemon died (clean shutdown, SIGKILL, or +// crash), live reality matches the DB: +// +// 1. Live pass: for each non-terminated session, adopt it if its runtime +// survived, else capture work and mark terminated (reconcileLive). +// 2. Reap pass: for each terminated session whose runtime leaked, kill it +// (reconcileReap). Runs before restore so a restored session does not +// collide with a leaked tmux of the same name. +// 3. Restore pass: relaunch shutdown-saved sessions (existing RestoreAll). +// +// Best-effort throughout: a per-session failure is logged and never aborts the +// pass or blocks boot. +func (m *Manager) Reconcile(ctx context.Context) error { + recs, err := m.store.ListAllSessions(ctx) + if err != nil { + return fmt.Errorf("reconcile: list sessions: %w", err) + } + for _, rec := range recs { + if rec.IsTerminated { + continue + } + if err := m.reconcileLive(ctx, rec); err != nil { + m.logger.Error("reconcile: live pass failed, skipping", "sessionID", rec.ID, "error", err) + } + } + for _, rec := range recs { + if !rec.IsTerminated { + continue + } + if err := m.reconcileReap(ctx, rec); err != nil { + m.logger.Error("reconcile: reap pass failed, skipping", "sessionID", rec.ID, "error", err) + } + } + return m.RestoreAll(ctx) +} +``` + +> Note: the live pass re-reads `rec.IsTerminated` from the pre-pass snapshot, so a session terminated *by* the live pass is not also reaped in the same run. That is fine: its tmux is already gone (that is why it was terminated), so reaping would be a no-op anyway. + +- [ ] **Step 4: Run the tests, verify they pass** + +Run: `cd backend && go test ./internal/session_manager/ -run 'TestReconcile' -v` +Expected: PASS (live + reap tests). + +- [ ] **Step 5: Commit** + +```bash +cd backend && gofmt -w internal/session_manager/manager.go internal/session_manager/manager_test.go +git add internal/session_manager/manager.go internal/session_manager/manager_test.go +git -c user.email=dev@theharshitsingh.com commit -m "feat(session): reconcile reap pass and Reconcile entry point" +``` + +--- + +## Task 3: Wire `Reconcile` into daemon boot + +**Files:** +- Modify: `backend/internal/daemon/lifecycle_wiring.go:64-67` (interface) +- Modify: `backend/internal/daemon/daemon.go:144-149` (boot call) +- Test: `backend/internal/daemon/wiring_test.go` + +**Interfaces:** +- Consumes: `Manager.Reconcile` (Task 2). +- Produces: `sessionLifecycle` interface gains `Reconcile(ctx context.Context) error`. + +- [ ] **Step 1: Update the wiring test/mock** + +In `wiring_test.go`, find the type used as a `sessionLifecycle` test double (it implements `RestoreAll` and `SaveAndTeardownAll`). Add a `Reconcile` method and, if the test asserts boot behavior, assert `Reconcile` is the method called on boot: + +```go +func (m *fakeSessionLifecycle) Reconcile(ctx context.Context) error { + m.reconcileCalls++ + return m.reconcileErr +} +``` + +(If `wiring_test.go` has no such double and only checks construction, add a compile-time assertion instead: `var _ sessionLifecycle = (*sessionmanager.Manager)(nil)` in the test, which fails to compile until both the interface and the concrete method exist.) + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `cd backend && go test ./internal/daemon/ -run Wiring -v` +Expected: FAIL — `Reconcile` not in the interface / not asserted. + +- [ ] **Step 3: Add `Reconcile` to the interface** + +In `lifecycle_wiring.go`: + +```go +type sessionLifecycle interface { + Reconcile(ctx context.Context) error + RestoreAll(ctx context.Context) error + SaveAndTeardownAll(ctx context.Context) error +} +``` + +- [ ] **Step 4: Replace the boot call** + +In `daemon.go`, change the boot restore call (currently lines 144-149) to call `Reconcile`: + +```go + // Reconcile sessions on boot: adopt crash-surviving runtimes, capture and + // terminate dead ones, reap leaked tmux, then restore shutdown-saved + // sessions. Best-effort: a failure is logged but never blocks boot. Placed + // before srv.Run so sessions are consistent before the server serves. + if reconcileErr := sessMgr.Reconcile(ctx); reconcileErr != nil { + log.Error("reconcile sessions on boot failed", "err", reconcileErr) + } +``` + +- [ ] **Step 5: Run the tests, verify they pass** + +Run: `cd backend && go test ./internal/daemon/ -v` +Expected: PASS. + +- [ ] **Step 6: Build everything** + +Run: `cd backend && go build ./... && go vet ./internal/daemon/ ./internal/session_manager/` +Expected: success. + +- [ ] **Step 7: Commit** + +```bash +cd backend && gofmt -w internal/daemon/daemon.go internal/daemon/lifecycle_wiring.go internal/daemon/wiring_test.go +git add internal/daemon/daemon.go internal/daemon/lifecycle_wiring.go internal/daemon/wiring_test.go +git -c user.email=dev@theharshitsingh.com commit -m "feat(daemon): run Reconcile on boot in place of bare RestoreAll" +``` + +--- + +## Task 4: Integration test over the sqlite store + +**Files:** +- Modify: `backend/internal/integration/lifecycle_sqlite_test.go` + +**Interfaces:** +- Consumes: the real `Manager.Reconcile`, a real sqlite store, and the test's runtime fake (find how this file already fakes the runtime; reuse it, scripting `IsAlive` per handle). + +- [ ] **Step 1: Read the existing integration harness** + +Open `backend/internal/integration/lifecycle_sqlite_test.go`. Identify how it constructs a `Manager` with a real `sqlite.Store` and what runtime double it injects. Reuse that exact wiring; only add `IsAlive` scripting to the double if it is missing. + +- [ ] **Step 2: Write the failing integration test** + +Add a test that seeds two sessions through the store, runs `Reconcile`, and asserts the resulting DB state. Use the file's existing seeding helpers and constructor names (match them; do not invent new ones): + +```go +func TestReconcile_TerminatesDeadLiveSessionAndReapsLeakedTmux(t *testing.T) { + // ... build store + manager the same way other tests in this file do ... + + // Seed A: is_terminated=0 but its runtime is gone (crash-killed agent). + // Seed B: is_terminated=1 but its tmux is still alive (leaked teardown). + // Script the runtime double: A's handle -> not alive, B's handle -> alive. + + if err := mgr.Reconcile(ctx); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + // A is now terminated in the store. + a, _, _ := store.GetSession(ctx, "A") + if !a.IsTerminated { + t.Fatalf("session A: want terminated after reconcile") + } + // B's leaked tmux was destroyed. + if !runtimeDouble.wasDestroyed("B") { + t.Fatalf("session B: want leaked tmux destroyed") + } +} +``` + +> Replace `mgr`, `store`, `ctx`, `runtimeDouble`, and the seeding with the file's actual identifiers. If the file's runtime double cannot report `wasDestroyed`, assert via the store/observable side effects it already uses. + +- [ ] **Step 3: Run it, verify it fails** + +Run: `cd backend && go test ./internal/integration/ -run TestReconcile_TerminatesDeadLiveSessionAndReapsLeakedTmux -v` +Expected: FAIL (until seeding + assertions match real helpers; iterate until it compiles and fails for the right reason, then passes once Reconcile runs). + +- [ ] **Step 4: Make it pass** + +The production code from Tasks 1-3 already implements the behavior. Adjust only the test scaffolding (identifiers, seeding) until it passes. + +Run: `cd backend && go test ./internal/integration/ -run TestReconcile -v` +Expected: PASS. + +- [ ] **Step 5: Run the full backend suite** + +Run: `cd backend && go test ./...` +Expected: PASS (no regressions in session_manager, daemon, integration). + +- [ ] **Step 6: Commit** + +```bash +cd backend && gofmt -w internal/integration/lifecycle_sqlite_test.go +git add internal/integration/lifecycle_sqlite_test.go +git -c user.email=dev@theharshitsingh.com commit -m "test(integration): reconcile terminates dead-live sessions and reaps leaked tmux" +``` + +--- + +## Task 5: Frontend wedged-orphan kill+replace branch + +**Files:** +- Modify: `frontend/src/main.ts` (in `startDaemonInner`, around lines 457-495) +- Test: `frontend/src/main.test.ts` or the existing main-process test file + +**Interfaces:** +- Consumes: existing `inspectExistingDaemon`, `resolveDaemonFromPort`, `readDaemonProbe`, `killDaemon`, `parseRunFile`/`defaultRunFilePath`, `expectedDaemonPort`. +- Produces: a pure decision helper, e.g. `function planDaemonTakeover(probe: DaemonProbe | null): "reuse" | "replace"`, unit-testable without spawning. + +- [ ] **Step 1: Read the current launch flow** + +Read `frontend/src/main.ts:432-512`. Confirm: `inspectExistingDaemon` returns a status when the run-file agrees with a live daemon; `resolveDaemonFromPort` attaches when a daemon answers the port. The gap: when a process holds the port but is unhealthy (no `/healthz` + `/readyz`) or identity-mismatched, today the code falls through to `spawn`, and the Go child then refuses the port and exits 1. We add: detect that case and kill the holder first. + +- [ ] **Step 2: Write the failing unit test for the decision helper** + +In the frontend test file: + +```ts +import { planDaemonTakeover } from "./main"; + +test("healthy probe -> reuse", () => { + expect(planDaemonTakeover({ healthy: true, pid: 123, port: 3001 })).toBe("reuse"); +}); + +test("port held but unhealthy probe -> replace", () => { + expect(planDaemonTakeover({ healthy: false, pid: 123, port: 3001 })).toBe("replace"); +}); + +test("no probe (nothing on port) -> replace (spawn fresh)", () => { + expect(planDaemonTakeover(null)).toBe("replace"); +}); +``` + +> Match `DaemonProbe`'s real shape from `frontend/src/shared/` (the `readDaemonProbe` return type). If it exposes health via a different field (e.g. presence of both `healthz` and `readyz`), encode that in `planDaemonTakeover` and the test rather than a `healthy` boolean. + +- [ ] **Step 3: Run it, verify it fails** + +Run: `cd frontend && npm test -- planDaemonTakeover` +Expected: FAIL — `planDaemonTakeover` not exported. + +- [ ] **Step 4: Implement the helper and wire the branch** + +Add the pure helper (top-level, exported) in `main.ts`: + +```ts +// planDaemonTakeover decides what to do with whatever currently holds the daemon +// port on launch. A healthy daemon is reused (it kept sessions alive across a +// crash). Anything else - an unhealthy/wedged holder, or nothing answering - means +// spawn fresh; the caller kills a live-but-unhealthy holder first. +export function planDaemonTakeover(probe: DaemonProbe | null): "reuse" | "replace" { + return probe?.healthy ? "reuse" : "replace"; +} +``` + +Then, in `startDaemonInner`, after the existing `inspectExistingDaemon` + `resolveDaemonFromPort` attach attempts fail (i.e. just before `spawn`), add: probe the expected port; if something answers but is unhealthy, SIGTERM the holder via the run-file PID and wait for the port to free before spawning. Concretely, before the `spawn(...)` at line 505: + +```ts + // A process may hold the port without being a healthy daemon we can attach to + // (wedged orphan from a crash, or a PID-dead-but-port-held run-file). Spawning + // then would make the Go child collide and exit 1. Detect it and clear it. + const holderProbe = await readDaemonProbe(expectedDaemonPort(process.env)); + if (planDaemonTakeover(holderProbe) === "replace" && holderProbe) { + const runFile = parseRunFile(await readRunFileSafe(defaultRunFilePath())); + if (runFile?.pid) { + try { + process.kill(-runFile.pid, "SIGTERM"); + } catch { + try { process.kill(runFile.pid, "SIGTERM"); } catch { /* already gone */ } + } + } + await waitForPortFree(expectedDaemonPort(process.env), 8_000); + await rmRunFileSafe(defaultRunFilePath()); + } +``` + +> Use the file's existing run-file read/parse helpers (`parseRunFile`, `defaultRunFilePath`). If `readRunFileSafe`/`rmRunFileSafe`/`waitForPortFree` do not exist, add small local helpers: `readRunFileSafe` wraps `fs.readFile` returning `""` on ENOENT; `rmRunFileSafe` wraps `fs.rm` ignoring ENOENT; `waitForPortFree` polls `readDaemonProbe` until it returns null or the timeout elapses. Keep each to a few lines, matching the file's existing async style. + +- [ ] **Step 5: Run the tests, verify they pass** + +Run: `cd frontend && npm test -- planDaemonTakeover` +Expected: PASS. + +- [ ] **Step 6: Type-check and lint the frontend** + +Run: `cd frontend && npm run typecheck && npm run lint` +Expected: success (commands per `frontend/package.json`; if names differ, use the repo's configured equivalents). + +- [ ] **Step 7: Commit** + +```bash +cd frontend +git add src/main.ts src/main.test.ts +git -c user.email=dev@theharshitsingh.com commit -m "feat(frontend): kill+replace a wedged orphan daemon on launch" +``` + +--- + +## Task 6: Full verification and branch wrap-up + +- [ ] **Step 1: Backend suite + lint** + +Run: `cd backend && go test ./... && gofmt -l . && go vet ./...` +Expected: tests PASS, `gofmt -l` prints nothing. If `golangci-lint` is installed: `golangci-lint run ./internal/session_manager/... ./internal/daemon/...` clean. + +- [ ] **Step 2: Frontend suite** + +Run: `cd frontend && npm test` +Expected: PASS. + +- [ ] **Step 3: Manual smoke (optional, real hardware)** + +With the app/daemon running and at least one session live, `kill -9` the daemon PID (from `~/.ao/running.json`), then relaunch. Expect: the live session's tmux is adopted (still listed, agent intact), no duplicate daemon, `running.json` repointed to the new PID. Then kill a session's agent and `kill -9` the daemon: expect that session marked terminated with its work in a `refs/ao/preserved/` ref, and no leaked tmux. + +- [ ] **Step 4: Review the diff against the spec** + +Confirm every spec section maps to a task: live pass (T1), reap + entry point (T2), boot wiring (T3), integration (T4), frontend takeover (T5). Confirm no worktree directory is ever deleted by reconcile and no agent is relaunched outside the existing `RestoreAll`. + +- [ ] **Step 5: Push the branch** + +```bash +git push -u origin feat/crash-proof-session-reconcile +``` + +--- + +## Self-Review notes (planning) + +- **Spec coverage:** Component 1 (live + reap matrix) -> Tasks 1-2; Component 2 (order of phases) -> Task 2 `Reconcile`; Component 3 (frontend kill+replace) -> Task 5; error-handling contract -> best-effort logging in every pass; testing section -> Tasks 1, 2, 4, 5. Deferred `ListSessions` is explicitly not implemented (matches spec Deferred). +- **Type consistency:** `IsAlive(ctx, ports.RuntimeHandle) (bool, error)` matches the concrete tmux/conpty signature (`tmux.go:176`). `Reconcile(ctx) error`, `reconcileLive(ctx, domain.SessionRecord) error`, `reconcileReap(ctx, domain.SessionRecord) error` are used identically across tasks. `runtimeHandle`/`workspaceInfo` helpers exist at `manager.go:1135,1139`. +- **Placeholder scan:** test bodies that depend on existing fakes' field names (`stashCalls`, `terminated`, `newManager`) carry an explicit instruction to match the file's real identifiers; this is unavoidable without the fakes in front of the implementer and is called out at each use, not left as a silent TODO. From 50314c6dc327efe514d6aea37df43b656da200b0 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 10:10:30 +0530 Subject: [PATCH 52/60] feat(session): reconcile live pass (adopt alive, stash+terminate dead) --- backend/internal/session_manager/manager.go | 34 ++++++++ .../internal/session_manager/manager_test.go | 77 ++++++++++++++++++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 4698e0d1..46c5275e 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -64,6 +64,9 @@ type lifecycleRecorder interface { type runtimeController interface { Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) Destroy(ctx context.Context, handle ports.RuntimeHandle) error + // IsAlive reports whether the handle's runtime session still exists. Used by + // Reconcile on boot to adopt crash-surviving sessions and reap leaked ones. + IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) } // Store is the persistence surface needed by the internal session Manager. @@ -622,6 +625,37 @@ func (m *Manager) saveAndTeardownOne(ctx context.Context, rec domain.SessionReco return nil } +// reconcileLive handles a single non-terminated session on boot. If its runtime +// session is still alive (tmux is the persistence layer, so it survives a daemon +// crash) we adopt it: a no-op, the agent keeps running. If the runtime is gone, +// the agent died with the daemon, so we capture any uncommitted work into a +// preserve ref (best-effort) and mark the session terminated. We never relaunch +// here (that is spawn policy) and never delete the worktree. +func (m *Manager) reconcileLive(ctx context.Context, rec domain.SessionRecord) error { + if rec.Metadata.WorkspacePath == "" || rec.Metadata.Branch == "" { + return nil + } + handle := runtimeHandle(rec.Metadata) + if handle.ID != "" { + alive, err := m.runtime.IsAlive(ctx, handle) + if err != nil { + // A failed probe is not proof of death: leave the session as-is. + return fmt.Errorf("reconcile %s: probe: %w", rec.ID, err) + } + if alive { + return nil // adopt: the session survived the crash. + } + } + // Runtime is gone: preserve work (best-effort) then mark terminated. + if _, err := m.workspace.StashUncommitted(ctx, workspaceInfo(rec)); err != nil { + m.logger.Warn("reconcile: stash uncommitted failed; marking terminated anyway", "sessionID", rec.ID, "error", err) + } + if err := m.lcm.MarkTerminated(ctx, rec.ID); err != nil { + return fmt.Errorf("reconcile %s: mark terminated: %w", rec.ID, err) + } + return nil +} + // RestoreAll relaunches every terminated session that was saved by the last // SaveAndTeardownAll. The "shutdown-saved" marker is the presence of a // session_worktrees row for the session; sessions the user killed before diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 69950573..1b26ead8 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -116,6 +116,8 @@ func (f *fakeStore) ListSessionWorktrees(_ context.Context, id domain.SessionID) type fakeLCM struct { store *fakeStore completed int + // terminated counts MarkTerminated calls per session id. + terminated map[domain.SessionID]int } func (l *fakeLCM) MarkSpawned(_ context.Context, id domain.SessionID, metadata domain.SessionMetadata) error { @@ -128,6 +130,10 @@ func (l *fakeLCM) MarkSpawned(_ context.Context, id domain.SessionID, metadata d return nil } func (l *fakeLCM) MarkTerminated(_ context.Context, id domain.SessionID) error { + if l.terminated == nil { + l.terminated = map[domain.SessionID]int{} + } + l.terminated[id]++ rec := l.store.sessions[id] rec.IsTerminated = true rec.Activity = domain.Activity{State: domain.ActivityExited, LastActivityAt: time.Now()} @@ -139,6 +145,10 @@ type fakeRuntime struct { createErr error created, destroyed int lastCfg ports.RuntimeConfig + // aliveByHandle maps a RuntimeHandle.ID to its liveness; missing = false. + aliveByHandle map[string]bool + aliveErr error + destroyedIDs []string } func (r *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { @@ -149,7 +159,17 @@ func (r *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports. r.created++ return ports.RuntimeHandle{ID: "h1"}, nil } -func (r *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { r.destroyed++; return nil } +func (r *fakeRuntime) Destroy(_ context.Context, handle ports.RuntimeHandle) error { + r.destroyed++ + r.destroyedIDs = append(r.destroyedIDs, handle.ID) + return nil +} +func (r *fakeRuntime) IsAlive(_ context.Context, handle ports.RuntimeHandle) (bool, error) { + if r.aliveErr != nil { + return false, r.aliveErr + } + return r.aliveByHandle[handle.ID], nil +} type fakeAgent struct{} @@ -226,6 +246,8 @@ type fakeWorkspace struct { stashErr error applyErr error forceDestroyErr error + // stashCalls counts StashUncommitted invocations. + stashCalls int // calls records the sequence of workspace method calls for ordering assertions. calls []string // sharedLog, when non-nil, receives entries alongside calls so ordering @@ -260,6 +282,7 @@ func (w *fakeWorkspace) ForceDestroy(_ context.Context, info ports.WorkspaceInfo return w.forceDestroyErr } func (w *fakeWorkspace) StashUncommitted(_ context.Context, info ports.WorkspaceInfo) (string, error) { + w.stashCalls++ entry := "StashUncommitted:" + string(info.SessionID) w.calls = append(w.calls, entry) if w.sharedLog != nil { @@ -1496,3 +1519,55 @@ func TestRestoreAll_ConflictLogsAndContinues(t *testing.T) { t.Fatalf("session must still relaunch after conflict, runtime.Create called %d times", rt.created) } } + +func TestReconcileLive_DeadSessionStashedAndTerminated(t *testing.T) { + st := newFakeStore() + rt := &fakeRuntime{aliveByHandle: map[string]bool{}} // handle not alive + ws := &fakeWorkspace{stashRef: "refs/ao/preserved/s1"} + lcm := &fakeLCM{store: st} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: lcm, LookPath: lookPath}) + + rec := domain.SessionRecord{ + ID: "s1", + ProjectID: "p1", + IsTerminated: false, + Metadata: domain.SessionMetadata{ + Branch: "ao/s1/root", WorkspacePath: "/wt/s1", RuntimeHandleID: "s1", + }, + } + + if err := m.reconcileLive(context.Background(), rec); err != nil { + t.Fatalf("reconcileLive: %v", err) + } + if ws.stashCalls != 1 { + t.Fatalf("StashUncommitted calls = %d, want 1", ws.stashCalls) + } + if lcm.terminated["s1"] != 1 { + t.Fatalf("MarkTerminated(s1) = %d, want 1", lcm.terminated["s1"]) + } + if rt.destroyed != 0 { + t.Fatalf("Destroy calls = %d, want 0 (dead session: no tmux to kill)", rt.destroyed) + } +} + +func TestReconcileLive_AliveSessionAdoptedNoop(t *testing.T) { + st := newFakeStore() + rt := &fakeRuntime{aliveByHandle: map[string]bool{"s2": true}} + ws := &fakeWorkspace{} + lcm := &fakeLCM{store: st} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: lcm, LookPath: lookPath}) + + rec := domain.SessionRecord{ + ID: "s2", ProjectID: "p1", IsTerminated: false, + Metadata: domain.SessionMetadata{Branch: "ao/s2/root", WorkspacePath: "/wt/s2", RuntimeHandleID: "s2"}, + } + + if err := m.reconcileLive(context.Background(), rec); err != nil { + t.Fatalf("reconcileLive: %v", err) + } + if ws.stashCalls != 0 || lcm.terminated["s2"] != 0 || rt.destroyed != 0 { + t.Fatalf("adopt should be a no-op: stash=%d term=%d destroy=%d", ws.stashCalls, lcm.terminated["s2"], rt.destroyed) + } +} From 9aebda69b86cd482c37d98246469b62bec7e5b8a Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 10:14:29 +0530 Subject: [PATCH 53/60] feat(session): reconcile reap pass and Reconcile entry point --- backend/internal/session_manager/manager.go | 59 +++++++++++++++++++ .../internal/session_manager/manager_test.go | 41 +++++++++++++ 2 files changed, 100 insertions(+) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 46c5275e..7285fe37 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -656,6 +656,65 @@ func (m *Manager) reconcileLive(ctx context.Context, rec domain.SessionRecord) e return nil } +// reconcileReap kills the leaked tmux session of a session the DB already marks +// terminated. This covers the teardown that marked the row terminated but failed +// to kill the runtime (e.g. ForceDestroy/Destroy errored after MarkTerminated). +// Destroy is idempotent, so an already-gone session is a no-op. +func (m *Manager) reconcileReap(ctx context.Context, rec domain.SessionRecord) error { + handle := runtimeHandle(rec.Metadata) + if handle.ID == "" { + return nil + } + alive, err := m.runtime.IsAlive(ctx, handle) + if err != nil { + return fmt.Errorf("reconcile reap %s: probe: %w", rec.ID, err) + } + if !alive { + return nil + } + if err := m.runtime.Destroy(ctx, handle); err != nil { + return fmt.Errorf("reconcile reap %s: destroy: %w", rec.ID, err) + } + return nil +} + +// Reconcile is the boot-time consistency pass. It replaces the bare RestoreAll +// call so that however the previous daemon died (clean shutdown, SIGKILL, or +// crash), live reality matches the DB: +// +// 1. Live pass: for each non-terminated session, adopt it if its runtime +// survived, else capture work and mark terminated (reconcileLive). +// 2. Reap pass: for each terminated session whose runtime leaked, kill it +// (reconcileReap). Runs before restore so a restored session does not +// collide with a leaked tmux of the same name. +// 3. Restore pass: relaunch shutdown-saved sessions (existing RestoreAll). +// +// Best-effort throughout: a per-session failure is logged and never aborts the +// pass or blocks boot. +func (m *Manager) Reconcile(ctx context.Context) error { + recs, err := m.store.ListAllSessions(ctx) + if err != nil { + return fmt.Errorf("reconcile: list sessions: %w", err) + } + for _, rec := range recs { + if rec.IsTerminated { + continue + } + if err := m.reconcileLive(ctx, rec); err != nil { + m.logger.Error("reconcile: live pass failed, skipping", "sessionID", rec.ID, "error", err) + } + } + for _, rec := range recs { + if !rec.IsTerminated { + continue + } + if err := m.reconcileReap(ctx, rec); err != nil { + m.logger.Error("reconcile: reap pass failed, skipping", "sessionID", rec.ID, "error", err) + } + } + return m.RestoreAll(ctx) +} + // RestoreAll relaunches every terminated session that was saved by the last // SaveAndTeardownAll. The "shutdown-saved" marker is the presence of a // session_worktrees row for the session; sessions the user killed before diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 1b26ead8..79f8d798 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -1571,3 +1571,44 @@ func TestReconcileLive_AliveSessionAdoptedNoop(t *testing.T) { t.Fatalf("adopt should be a no-op: stash=%d term=%d destroy=%d", ws.stashCalls, lcm.terminated["s2"], rt.destroyed) } } + +func TestReconcileReap_TerminatedButAliveTmuxDestroyed(t *testing.T) { + st := newFakeStore() + rt := &fakeRuntime{aliveByHandle: map[string]bool{"t1": true}} + ws := &fakeWorkspace{} + lcm := &fakeLCM{store: st} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: lcm, LookPath: lookPath}) + + rec := domain.SessionRecord{ + ID: "t1", ProjectID: "p1", IsTerminated: true, + Metadata: domain.SessionMetadata{RuntimeHandleID: "t1"}, + } + + if err := m.reconcileReap(context.Background(), rec); err != nil { + t.Fatalf("reconcileReap: %v", err) + } + if len(rt.destroyedIDs) != 1 || rt.destroyedIDs[0] != "t1" { + t.Fatalf("destroyedIDs = %v, want [t1]", rt.destroyedIDs) + } +} + +func TestReconcileReap_TerminatedAndDeadTmuxLeftAlone(t *testing.T) { + st := newFakeStore() + rt := &fakeRuntime{aliveByHandle: map[string]bool{}} // t2 not alive + ws := &fakeWorkspace{} + lcm := &fakeLCM{store: st} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: lcm, LookPath: lookPath}) + + rec := domain.SessionRecord{ + ID: "t2", ProjectID: "p1", IsTerminated: true, + Metadata: domain.SessionMetadata{RuntimeHandleID: "t2"}, + } + if err := m.reconcileReap(context.Background(), rec); err != nil { + t.Fatalf("reconcileReap: %v", err) + } + if rt.destroyed != 0 { + t.Fatalf("Destroy calls = %d, want 0", rt.destroyed) + } +} From ce03ba8a48633a72bd2c3b2f8f019b1a1691d567 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 10:17:52 +0530 Subject: [PATCH 54/60] feat(daemon): run Reconcile on boot in place of bare RestoreAll --- backend/internal/daemon/daemon.go | 11 ++++----- backend/internal/daemon/lifecycle_wiring.go | 1 + backend/internal/daemon/wiring_test.go | 25 ++++++++++++++++----- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 03885c1d..3603fbe9 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -141,11 +141,12 @@ func Run() error { return err } - // Restore sessions saved by the previous daemon shutdown. Best-effort: a - // failure is logged but never blocks boot. RestoreAll is placed here (before - // srv.Run) to ensure sessions are live before the server starts serving. - if restoreErr := sessMgr.RestoreAll(ctx); restoreErr != nil { - log.Error("restore sessions on boot failed", "err", restoreErr) + // Reconcile sessions on boot: adopt crash-surviving runtimes, capture and + // terminate dead ones, reap leaked tmux, then restore shutdown-saved + // sessions. Best-effort: a failure is logged but never blocks boot. Placed + // before srv.Run so sessions are consistent before the server serves. + if reconcileErr := sessMgr.Reconcile(ctx); reconcileErr != nil { + log.Error("reconcile sessions on boot failed", "err", reconcileErr) } runErr := srv.Run(ctx) diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index ceccce24..152111e6 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -62,6 +62,7 @@ func (l *lifecycleStack) Stop() { // boot/shutdown wiring. A minimal interface keeps the daemon testable without // depending on the concrete manager type. type sessionLifecycle interface { + Reconcile(ctx context.Context) error RestoreAll(ctx context.Context) error SaveAndTeardownAll(ctx context.Context) error } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index a3657f87..21dbcbd5 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -381,16 +381,23 @@ func TestProjectRepoResolver_ResolvesRegisteredProject(t *testing.T) { } } -// fakeSessionLifecycle records calls to RestoreAll and SaveAndTeardownAll so -// tests can assert the daemon wiring invokes both without needing a real runtime -// or worktree. +// fakeSessionLifecycle records calls to Reconcile, RestoreAll, and +// SaveAndTeardownAll so tests can assert the daemon wiring invokes the correct +// methods without needing a real runtime or worktree. type fakeSessionLifecycle struct { + reconcileCalled bool restoreAllCalled bool saveAndTeardownCalled bool + reconcileErr error restoreErr error saveErr error } +func (f *fakeSessionLifecycle) Reconcile(_ context.Context) error { + f.reconcileCalled = true + return f.reconcileErr +} + func (f *fakeSessionLifecycle) RestoreAll(_ context.Context) error { f.restoreAllCalled = true return f.restoreErr @@ -403,8 +410,9 @@ func (f *fakeSessionLifecycle) SaveAndTeardownAll(_ context.Context) error { // TestWiring_SessionLifecycleInterfaceInvokedByDaemon asserts the // sessionLifecycle interface is satisfied by *sessionmanager.Manager (compile -// check) and that both methods dispatch correctly through the interface, matching -// what daemon.go wires at boot/shutdown. +// check) and that Reconcile, RestoreAll, and SaveAndTeardownAll dispatch +// correctly through the interface, matching what daemon.go wires at +// boot/shutdown. func TestWiring_SessionLifecycleInterfaceInvokedByDaemon(t *testing.T) { // Verify *sessionmanager.Manager satisfies the interface at compile time. var _ sessionLifecycle = (*sessionmanager.Manager)(nil) @@ -416,6 +424,13 @@ func TestWiring_SessionLifecycleInterfaceInvokedByDaemon(t *testing.T) { // path, not just direct struct method calls. var sl sessionLifecycle = fake + if err := sl.Reconcile(ctx); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if !fake.reconcileCalled { + t.Fatal("Reconcile was not called through the interface") + } + if err := sl.RestoreAll(ctx); err != nil { t.Fatalf("RestoreAll: %v", err) } From 245a761338cbe965a7e4356210d4bc3ebb356a0f Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 10:21:46 +0530 Subject: [PATCH 55/60] test(integration): reconcile terminates dead-live sessions and reaps leaked tmux Co-Authored-By: Claude Sonnet 4.6 --- .../integration/lifecycle_sqlite_test.go | 126 +++++++++++++++++- 1 file changed, 122 insertions(+), 4 deletions(-) diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index 0add8350..78024b5e 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -15,14 +15,43 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) -type stubRuntime struct{ created, destroyed int } +type stubRuntime struct { + created int + destroyed int + // aliveByHandle scripts IsAlive per handle ID. If a handle ID is absent, + // IsAlive returns true (default: alive), matching the pre-existing behavior + // that all other tests relied on. + aliveByHandle map[string]bool + destroyedHandles []string +} func (s *stubRuntime) Create(context.Context, ports.RuntimeConfig) (ports.RuntimeHandle, error) { s.created++ return ports.RuntimeHandle{ID: "h1"}, nil } -func (s *stubRuntime) Destroy(context.Context, ports.RuntimeHandle) error { s.destroyed++; return nil } -func (s *stubRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { return true, nil } +func (s *stubRuntime) Destroy(_ context.Context, h ports.RuntimeHandle) error { + s.destroyed++ + s.destroyedHandles = append(s.destroyedHandles, h.ID) + return nil +} +func (s *stubRuntime) IsAlive(_ context.Context, h ports.RuntimeHandle) (bool, error) { + if s.aliveByHandle != nil { + if alive, ok := s.aliveByHandle[h.ID]; ok { + return alive, nil + } + } + return true, nil +} + +// wasDestroyed reports whether Destroy was called with the given handle ID. +func (s *stubRuntime) wasDestroyed(handleID string) bool { + for _, id := range s.destroyedHandles { + if id == handleID { + return true + } + } + return false +} type stubAgent struct{} @@ -81,6 +110,7 @@ func (c *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg strin type stack struct { store *sqlite.Store sm *sessionsvc.Service + mgr *sessionmanager.Manager lcm *lifecycle.Manager prm *prsvc.Manager rt *stubRuntime @@ -114,7 +144,7 @@ func newStack(t *testing.T) *stack { ws := &stubWorkspace{} mgr := sessionmanager.New(sessionmanager.Deps{Runtime: rt, Agents: stubAgents{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm, LookPath: func(string) (string, error) { return "/usr/bin/true", nil }}) sm := sessionsvc.New(mgr, store) - return &stack{store: store, sm: sm, lcm: lcm, prm: prm, rt: rt, ws: ws, msg: msg} + return &stack{store: store, sm: sm, mgr: mgr, lcm: lcm, prm: prm, rt: rt, ws: ws, msg: msg} } func TestSpawnPRKillRoundTrip(t *testing.T) { @@ -175,6 +205,94 @@ func TestRestoreRoundTripPreservesMetadata(t *testing.T) { } } +// TestReconcile_TerminatesDeadLiveSessionAndReapsLeakedTmux exercises +// Manager.Reconcile against a real sqlite.Store: +// +// - Session A: is_terminated=0 but its runtime is GONE => Reconcile must +// mark it terminated in the DB. +// - Session B: is_terminated=1 but its runtime is still ALIVE (leaked teardown) +// => Reconcile must call Destroy on its handle. +func TestReconcile_TerminatesDeadLiveSessionAndReapsLeakedTmux(t *testing.T) { + ctx := context.Background() + st := newStack(t) + + // Script liveness: handle "hdl-A" is dead; handle "hdl-B" is alive. + st.rt.aliveByHandle = map[string]bool{ + "hdl-A": false, + "hdl-B": true, + } + + now := time.Now().UTC() + + // Seed session A: live in the DB (is_terminated=0) but runtime is gone. + // WorkspacePath and Branch must be non-empty so reconcileLive actually probes + // IsAlive (it short-circuits on missing path/branch). + recA := domain.SessionRecord{ + ProjectID: "mer", + Kind: domain.KindWorker, + Harness: domain.HarnessClaudeCode, + IsTerminated: false, + Metadata: domain.SessionMetadata{ + Branch: "ao/mer-a/root", + WorkspacePath: "/ws/mer-a", + RuntimeHandleID: "hdl-A", + }, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, + CreatedAt: now, + UpdatedAt: now, + } + recA, err := st.store.CreateSession(ctx, recA) + if err != nil { + t.Fatalf("seed session A: %v", err) + } + + // Seed session B: terminated in the DB (is_terminated=1) but runtime leaked. + recB := domain.SessionRecord{ + ProjectID: "mer", + Kind: domain.KindWorker, + Harness: domain.HarnessClaudeCode, + IsTerminated: true, + Metadata: domain.SessionMetadata{ + Branch: "ao/mer-b/root", + WorkspacePath: "/ws/mer-b", + RuntimeHandleID: "hdl-B", + }, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, + CreatedAt: now, + UpdatedAt: now, + } + recB, err = st.store.CreateSession(ctx, recB) + if err != nil { + t.Fatalf("seed session B: %v", err) + } + // CreateSession always inserts is_terminated=0; patch it to terminated. + recB.IsTerminated = true + if err := st.store.UpdateSession(ctx, recB); err != nil { + t.Fatalf("patch session B terminated: %v", err) + } + + if err := st.mgr.Reconcile(ctx); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + // Session A must now be terminated in the store. + gotA, ok, err := st.store.GetSession(ctx, recA.ID) + if err != nil { + t.Fatalf("get session A: %v", err) + } + if !ok { + t.Fatalf("session A: not found after Reconcile") + } + if !gotA.IsTerminated { + t.Fatalf("session A: want is_terminated=true after Reconcile, got false") + } + + // Session B's leaked runtime must have been destroyed. + if !st.rt.wasDestroyed("hdl-B") { + t.Fatalf("session B: want Destroy called for handle hdl-B; destroyed handles: %v", st.rt.destroyedHandles) + } +} + func TestCDCPollerReceivesSessionAndPREvents(t *testing.T) { ctx := context.Background() st := newStack(t) From 6791cd6f49ec41783c0f4e2152dc18693cee2881 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 10:25:21 +0530 Subject: [PATCH 56/60] test(integration): correct misleading CreateSession comment in reconcile test --- backend/internal/integration/lifecycle_sqlite_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index 78024b5e..4b70fb25 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -265,8 +265,7 @@ func TestReconcile_TerminatesDeadLiveSessionAndReapsLeakedTmux(t *testing.T) { if err != nil { t.Fatalf("seed session B: %v", err) } - // CreateSession always inserts is_terminated=0; patch it to terminated. - recB.IsTerminated = true + // recB is already built with IsTerminated=true, so CreateSession stores it terminated; the UpdateSession below is redundant but kept for clarity. if err := st.store.UpdateSession(ctx, recB); err != nil { t.Fatalf("patch session B terminated: %v", err) } From 284404f9d6e09625ccf31717a1c9a3af78ce216f Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 10:30:07 +0530 Subject: [PATCH 57/60] feat(frontend): kill+replace a wedged orphan daemon on launch When both inspectExistingDaemon and resolveDaemonFromPort return null but a process still holds the daemon port (a crashed/orphaned daemon), spawning a new Go child would collide on the port and exit 1. Detect this case, SIGTERM the holder (via the run-file PID, falling back to the probe PID), poll until the port is free (up to 8s), clear the stale run-file, then proceed to spawn fresh. The healthy-daemon reuse path is unchanged. Pure helper: src/shared/daemon-takeover.ts (planDaemonTakeover) Unit tests: src/shared/daemon-takeover.test.ts (3 tests, TDD red-green) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/main.ts | 56 ++++++++++++++++++++- frontend/src/shared/daemon-takeover.test.ts | 31 ++++++++++++ frontend/src/shared/daemon-takeover.ts | 32 ++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 frontend/src/shared/daemon-takeover.test.ts create mode 100644 frontend/src/shared/daemon-takeover.ts diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 2fdbab0a..887df0fe 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -14,7 +14,7 @@ import { import { updateElectronApp } from "update-electron-app"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { readFile, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -28,6 +28,7 @@ import { resolveDaemonFromPort, resolveDaemonFromRunFile, } from "./shared/daemon-attach"; +import { planDaemonTakeover } from "./shared/daemon-takeover"; import { buildDaemonEnv, resolveShellEnv, type ShellRunner } from "./shared/shell-env"; import { DEFAULT_POSTHOG_HOST, DEFAULT_POSTHOG_PROJECT_KEY } from "./shared/posthog-config"; import { buildTelemetryBootstrap } from "./shared/telemetry"; @@ -484,6 +485,59 @@ async function startDaemonInner(startEpoch: number): Promise { return daemonStatus; } + // Wedged-orphan kill+replace: both attach paths returned null, but a process + // may still be holding the port (a crashed daemon that left its socket open, + // or a PID-dead run-file whose port is still bound). Spawning a new daemon + // then would make the Go child collide and exit 1. Detect it: probe the port + // once more (raw healthz, ignoring service/identity), and if something + // answers, kill it via the run-file PID before spawning. + // + // We intentionally skip the identity checks here: at this point we already + // know the port holder did NOT pass our identity checks (planDaemonTakeover + // got a probe that resolveDaemonFromPort rejected), so we should kill it + // regardless of whose binary it is. + const orphanProbe = await readDaemonProbe(expectedDaemonPort(process.env), "healthz"); + if (planDaemonTakeover(orphanProbe) === "replace") { + const runFilePath_ = runFilePath(); + let runFilePid: number | null = null; + if (runFilePath_) { + try { + const contents = await readFile(runFilePath_, "utf8"); + runFilePid = parseRunFile(contents)?.pid ?? null; + } catch { + // run-file absent or unreadable; proceed without a PID to kill + } + } + // Use the PID from the run-file when available; fall back to the probe's + // reported PID as a last resort (a wedged daemon may not have written a + // fresh run-file). + const pidToKill = runFilePid ?? orphanProbe?.pid ?? null; + if (pidToKill) { + try { + process.kill(-pidToKill, "SIGTERM"); + } catch { + try { + process.kill(pidToKill, "SIGTERM"); + } catch { + // process already gone; proceed + } + } + } + // Poll until the port is free (probe returns null) or 8 s elapses. + const TAKEOVER_TIMEOUT_MS = 8_000; + const TAKEOVER_POLL_MS = 200; + const deadline = Date.now() + TAKEOVER_TIMEOUT_MS; + while (Date.now() < deadline) { + const still = await readDaemonProbe(expectedDaemonPort(process.env), "healthz"); + if (!still) break; + await new Promise((r) => setTimeout(r, TAKEOVER_POLL_MS)); + } + // Remove the stale run-file so the new daemon can write a fresh one. + if (runFilePath_) { + await rm(runFilePath_, { force: true }); + } + } + if (launch.source === "bundled" && !existsSync(launch.command)) { setDaemonStatus({ state: "error", diff --git a/frontend/src/shared/daemon-takeover.test.ts b/frontend/src/shared/daemon-takeover.test.ts new file mode 100644 index 00000000..366b19b4 --- /dev/null +++ b/frontend/src/shared/daemon-takeover.test.ts @@ -0,0 +1,31 @@ +// Unit tests for planDaemonTakeover. Run with: +// cd frontend && npx vitest run src/shared/daemon-takeover.test.ts +import { describe, expect, it } from "vitest"; +import { DAEMON_SERVICE_NAME, type DaemonProbe } from "./daemon-attach"; +import { planDaemonTakeover } from "./daemon-takeover"; + +// A minimal valid DaemonProbe (non-null means the AO daemon answered /healthz). +const healthyProbe: DaemonProbe = { + status: "ok", + service: DAEMON_SERVICE_NAME, + pid: 1234, +}; + +describe("planDaemonTakeover", () => { + it("returns reuse when a valid probe answered (healthy daemon is live)", () => { + expect(planDaemonTakeover(healthyProbe)).toBe("reuse"); + }); + + it("returns reuse with optional identity fields present", () => { + const probeWithIdentity: DaemonProbe = { + ...healthyProbe, + executablePath: "/usr/local/bin/ao", + workingDirectory: "/work/backend", + }; + expect(planDaemonTakeover(probeWithIdentity)).toBe("reuse"); + }); + + it("returns replace when probe is null (nothing valid answered the port)", () => { + expect(planDaemonTakeover(null)).toBe("replace"); + }); +}); diff --git a/frontend/src/shared/daemon-takeover.ts b/frontend/src/shared/daemon-takeover.ts new file mode 100644 index 00000000..63ad1118 --- /dev/null +++ b/frontend/src/shared/daemon-takeover.ts @@ -0,0 +1,32 @@ +// Pure decision helper for the wedged-orphan kill+replace path. +// +// Context: on app launch, after both attach attempts fail (inspectExistingDaemon +// and resolveDaemonFromPort both returned null/non-ready), a process may still +// be holding the daemon port. Spawning a new daemon then makes the Go child +// collide on the port and exit 1. This helper encodes the decision: a non-null +// probe means a genuine AO daemon answered /healthz, so the caller should reuse +// it via the normal attach path. A null probe means nothing valid answered, so +// the caller should proceed to the replace path (kill any PID the run-file names, +// wait for the port to free, remove the stale run-file, then spawn fresh). +// +// Kept side-effect free and dependency-injected (no node:* or electron imports) +// so it can be exercised in vitest without the Electron polyfill layer. + +import type { DaemonProbe } from "./daemon-attach"; + +/** + * Decide what to do with whatever currently occupies the daemon port. + * + * Returns "reuse" when a valid AO daemon answered /healthz (probe non-null): + * the caller should attach to it via the normal path. + * + * Returns "replace" when nothing valid answered (probe null): the caller should + * kill any process the run-file names, wait for the port to free, clear the + * stale run-file, then spawn a fresh daemon. + * + * ponytail: single null-check covers the entire decision surface; the probe's + * content (pid, executablePath) is for the caller's identity checks, not ours. + */ +export function planDaemonTakeover(probe: DaemonProbe | null): "reuse" | "replace" { + return probe !== null ? "reuse" : "replace"; +} From 4d8222c743803c3edc81209fa5b4219c91829ac4 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 10:36:27 +0530 Subject: [PATCH 58/60] fix(frontend): fire orphan-daemon takeover when a holder actually exists Replace planDaemonTakeover (inverted logic: ran kill block only when probe was null) with shouldReplacePortHolder(probe, holderPidAlive) which returns true when a real holder exists: non-null probe (rejected responder) OR a run-file PID that is still alive (hung holder). Update main.ts call site to compute PID liveness before gating the kill block. Update tests to cover all three distinct outcomes non-vacuously. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/main.ts | 45 ++++++++++----------- frontend/src/shared/daemon-takeover.test.ts | 24 +++++------ frontend/src/shared/daemon-takeover.ts | 33 ++++++++------- 3 files changed, 49 insertions(+), 53 deletions(-) diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 887df0fe..66b8c9ed 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -28,7 +28,7 @@ import { resolveDaemonFromPort, resolveDaemonFromRunFile, } from "./shared/daemon-attach"; -import { planDaemonTakeover } from "./shared/daemon-takeover"; +import { shouldReplacePortHolder } from "./shared/daemon-takeover"; import { buildDaemonEnv, resolveShellEnv, type ShellRunner } from "./shared/shell-env"; import { DEFAULT_POSTHOG_HOST, DEFAULT_POSTHOG_PROJECT_KEY } from "./shared/posthog-config"; import { buildTelemetryBootstrap } from "./shared/telemetry"; @@ -486,31 +486,28 @@ async function startDaemonInner(startEpoch: number): Promise { } // Wedged-orphan kill+replace: both attach paths returned null, but a process - // may still be holding the port (a crashed daemon that left its socket open, - // or a PID-dead run-file whose port is still bound). Spawning a new daemon - // then would make the Go child collide and exit 1. Detect it: probe the port - // once more (raw healthz, ignoring service/identity), and if something - // answers, kill it via the run-file PID before spawning. - // - // We intentionally skip the identity checks here: at this point we already - // know the port holder did NOT pass our identity checks (planDaemonTakeover - // got a probe that resolveDaemonFromPort rejected), so we should kill it - // regardless of whose binary it is. + // may still be holding the port. Kill a holder we could not attach to: either + // a rejected responder (answered /healthz but failed identity, probe non-null) + // or a hung holder that does not answer healthz but whose run-file PID is still + // alive. When neither condition holds there is nothing to kill; skip to spawn. const orphanProbe = await readDaemonProbe(expectedDaemonPort(process.env), "healthz"); - if (planDaemonTakeover(orphanProbe) === "replace") { - const runFilePath_ = runFilePath(); - let runFilePid: number | null = null; - if (runFilePath_) { - try { - const contents = await readFile(runFilePath_, "utf8"); - runFilePid = parseRunFile(contents)?.pid ?? null; - } catch { - // run-file absent or unreadable; proceed without a PID to kill - } + const runFilePath_ = runFilePath(); + let runFilePid: number | null = null; + if (runFilePath_) { + try { + runFilePid = parseRunFile(await readFile(runFilePath_, "utf8"))?.pid ?? null; + } catch { + // run-file absent or unreadable; proceed without a PID. } - // Use the PID from the run-file when available; fall back to the probe's - // reported PID as a last resort (a wedged daemon may not have written a - // fresh run-file). + } + // process.kill(pid, 0) does not kill; it throws iff the PID is not live. + let holderPidAlive = false; + if (runFilePid) { + try { process.kill(runFilePid, 0); holderPidAlive = true; } catch { holderPidAlive = false; } + } + if (shouldReplacePortHolder(orphanProbe, holderPidAlive)) { + // Use the run-file PID when available; fall back to the probe's reported + // PID as a last resort (a wedged daemon may not have written a fresh run-file). const pidToKill = runFilePid ?? orphanProbe?.pid ?? null; if (pidToKill) { try { diff --git a/frontend/src/shared/daemon-takeover.test.ts b/frontend/src/shared/daemon-takeover.test.ts index 366b19b4..7621501b 100644 --- a/frontend/src/shared/daemon-takeover.test.ts +++ b/frontend/src/shared/daemon-takeover.test.ts @@ -1,8 +1,8 @@ -// Unit tests for planDaemonTakeover. Run with: +// Unit tests for shouldReplacePortHolder. Run with: // cd frontend && npx vitest run src/shared/daemon-takeover.test.ts import { describe, expect, it } from "vitest"; import { DAEMON_SERVICE_NAME, type DaemonProbe } from "./daemon-attach"; -import { planDaemonTakeover } from "./daemon-takeover"; +import { shouldReplacePortHolder } from "./daemon-takeover"; // A minimal valid DaemonProbe (non-null means the AO daemon answered /healthz). const healthyProbe: DaemonProbe = { @@ -11,21 +11,17 @@ const healthyProbe: DaemonProbe = { pid: 1234, }; -describe("planDaemonTakeover", () => { - it("returns reuse when a valid probe answered (healthy daemon is live)", () => { - expect(planDaemonTakeover(healthyProbe)).toBe("reuse"); +describe("shouldReplacePortHolder", () => { + it("returns true when a probe answered (rejected responder, any holderPidAlive)", () => { + expect(shouldReplacePortHolder(healthyProbe, false)).toBe(true); + expect(shouldReplacePortHolder(healthyProbe, true)).toBe(true); }); - it("returns reuse with optional identity fields present", () => { - const probeWithIdentity: DaemonProbe = { - ...healthyProbe, - executablePath: "/usr/local/bin/ao", - workingDirectory: "/work/backend", - }; - expect(planDaemonTakeover(probeWithIdentity)).toBe("reuse"); + it("returns true when probe is null but the run-file PID is still alive (hung holder)", () => { + expect(shouldReplacePortHolder(null, true)).toBe(true); }); - it("returns replace when probe is null (nothing valid answered the port)", () => { - expect(planDaemonTakeover(null)).toBe("replace"); + it("returns false when probe is null and no live holder PID (nothing to kill)", () => { + expect(shouldReplacePortHolder(null, false)).toBe(false); }); }); diff --git a/frontend/src/shared/daemon-takeover.ts b/frontend/src/shared/daemon-takeover.ts index 63ad1118..418cdc4a 100644 --- a/frontend/src/shared/daemon-takeover.ts +++ b/frontend/src/shared/daemon-takeover.ts @@ -3,11 +3,12 @@ // Context: on app launch, after both attach attempts fail (inspectExistingDaemon // and resolveDaemonFromPort both returned null/non-ready), a process may still // be holding the daemon port. Spawning a new daemon then makes the Go child -// collide on the port and exit 1. This helper encodes the decision: a non-null -// probe means a genuine AO daemon answered /healthz, so the caller should reuse -// it via the normal attach path. A null probe means nothing valid answered, so -// the caller should proceed to the replace path (kill any PID the run-file names, -// wait for the port to free, remove the stale run-file, then spawn fresh). +// collide on the port and exit 1. This helper encodes the decision: kill the +// holder when something is provably holding the port, either because a process +// answered /healthz (probe non-null, already rejected by identity checks +// upstream) or because the run-file names a PID that is still alive (a hung +// holder that does not answer healthz but still binds the port). When neither +// holds, there is nothing to kill; skip straight to spawn. // // Kept side-effect free and dependency-injected (no node:* or electron imports) // so it can be exercised in vitest without the Electron polyfill layer. @@ -15,18 +16,20 @@ import type { DaemonProbe } from "./daemon-attach"; /** - * Decide what to do with whatever currently occupies the daemon port. + * Reports whether something is holding the daemon port that we must kill before + * spawning. By the time it is called, the healthy-reuse attach paths have already + * returned, so any holder here is one we could not attach to: a process answering + * healthz that failed identity (probe non-null), or a hung holder that does not + * answer but whose run-file PID is still alive. * - * Returns "reuse" when a valid AO daemon answered /healthz (probe non-null): - * the caller should attach to it via the normal path. + * Returns true when the caller should kill the holder, wait for the port to + * free, clear the stale run-file, then spawn a fresh daemon. * - * Returns "replace" when nothing valid answered (probe null): the caller should - * kill any process the run-file names, wait for the port to free, clear the - * stale run-file, then spawn a fresh daemon. + * Returns false when there is no detectable holder; spawn immediately. * - * ponytail: single null-check covers the entire decision surface; the probe's - * content (pid, executablePath) is for the caller's identity checks, not ours. + * ponytail: two-condition OR covers the entire decision surface; the probe's + * content (pid, executablePath) is for the caller's kill logic, not ours. */ -export function planDaemonTakeover(probe: DaemonProbe | null): "reuse" | "replace" { - return probe !== null ? "reuse" : "replace"; +export function shouldReplacePortHolder(probe: DaemonProbe | null, holderPidAlive: boolean): boolean { + return probe !== null || holderPidAlive; } From 711de8ff169e477e65ba263b32c9a388af10be75 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari Date: Wed, 24 Jun 2026 10:46:19 +0530 Subject: [PATCH 59/60] docs+test: accurate takeover comments, reconcileLive probe-error test, Reconcile doc Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/daemon/lifecycle_wiring.go | 2 +- .../internal/session_manager/manager_test.go | 35 +++++++++++++++++++ frontend/src/main.ts | 12 ++++--- frontend/src/shared/daemon-takeover.ts | 21 ++++++----- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 152111e6..676dcb8e 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -71,7 +71,7 @@ type sessionLifecycle interface { // over the selected runtime, a per-session gitworktree workspace, the shared // store + LCM, the per-session agent resolver, and the agent messenger. The // returned service is mounted at httpd APIDeps.Sessions. It also returns the -// manager so the caller can wire RestoreAll/SaveAndTeardownAll into the +// manager so the caller can wire Reconcile/SaveAndTeardownAll into the // boot/shutdown sequence. func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, sessionLifecycle, error) { defaultAgent := cfg.Agent diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 79f8d798..a6b1d0e7 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -1572,6 +1572,41 @@ func TestReconcileLive_AliveSessionAdoptedNoop(t *testing.T) { } } +// TestReconcileLive_ProbeErrorIsNotDeath locks the invariant that a failed +// IsAlive probe is NOT treated as proof that the session is dead. reconcileLive +// must propagate the error and must NOT stash, terminate, or destroy. +func TestReconcileLive_ProbeErrorIsNotDeath(t *testing.T) { + st := newFakeStore() + rt := &fakeRuntime{aliveErr: errors.New("probe boom")} + ws := &fakeWorkspace{} + lcm := &fakeLCM{store: st} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: lcm, LookPath: lookPath}) + + rec := domain.SessionRecord{ + ID: "s3", + ProjectID: "p1", + IsTerminated: false, + Metadata: domain.SessionMetadata{ + Branch: "ao/s3/root", WorkspacePath: "/wt/s3", RuntimeHandleID: "s3", + }, + } + + err := m.reconcileLive(context.Background(), rec) + if err == nil { + t.Fatal("reconcileLive: expected non-nil error on probe failure, got nil") + } + if ws.stashCalls != 0 { + t.Fatalf("StashUncommitted calls = %d, want 0 (probe error is not death)", ws.stashCalls) + } + if lcm.terminated["s3"] != 0 { + t.Fatalf("MarkTerminated(s3) = %d, want 0 (probe error is not death)", lcm.terminated["s3"]) + } + if rt.destroyed != 0 { + t.Fatalf("Destroy calls = %d, want 0 (probe error is not death)", rt.destroyed) + } +} + func TestReconcileReap_TerminatedButAliveTmuxDestroyed(t *testing.T) { st := newFakeStore() rt := &fakeRuntime{aliveByHandle: map[string]bool{"t1": true}} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 66b8c9ed..6f7bc180 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -486,10 +486,14 @@ async function startDaemonInner(startEpoch: number): Promise { } // Wedged-orphan kill+replace: both attach paths returned null, but a process - // may still be holding the port. Kill a holder we could not attach to: either - // a rejected responder (answered /healthz but failed identity, probe non-null) - // or a hung holder that does not answer healthz but whose run-file PID is still - // alive. When neither condition holds there is nothing to kill; skip to spawn. + // may still be holding the port. The only reachable case here is a hung/wedged + // holder whose run-file PID is still alive but is not answering /healthz (e.g. + // our own daemon that bound the port and then deadlocked). Two cases are + // intentionally NOT handled: an identity-mismatched but healthy AO daemon is + // already surfaced as an error status upstream by resolveDaemonFromPort (not + // killed here), and a foreign non-AO process holding the port with a dead + // run-file PID is not replaced (out of scope). When no holder is detectable, + // skip straight to spawn. const orphanProbe = await readDaemonProbe(expectedDaemonPort(process.env), "healthz"); const runFilePath_ = runFilePath(); let runFilePid: number | null = null; diff --git a/frontend/src/shared/daemon-takeover.ts b/frontend/src/shared/daemon-takeover.ts index 418cdc4a..98a0f58b 100644 --- a/frontend/src/shared/daemon-takeover.ts +++ b/frontend/src/shared/daemon-takeover.ts @@ -4,11 +4,12 @@ // and resolveDaemonFromPort both returned null/non-ready), a process may still // be holding the daemon port. Spawning a new daemon then makes the Go child // collide on the port and exit 1. This helper encodes the decision: kill the -// holder when something is provably holding the port, either because a process -// answered /healthz (probe non-null, already rejected by identity checks -// upstream) or because the run-file names a PID that is still alive (a hung -// holder that does not answer healthz but still binds the port). When neither -// holds, there is nothing to kill; skip straight to spawn. +// holder when the run-file names a PID that is still alive (a hung/wedged holder +// that bound the port but is not answering /healthz). The probe disjunct +// (probe !== null) is kept as a defensive guard only: by the time this helper +// is called, resolveDaemonFromPort already returned null, so a holder that +// answers /healthz at this point is unexpected. If it does happen (e.g. a race), +// we still replace it rather than colliding on spawn. // // Kept side-effect free and dependency-injected (no node:* or electron imports) // so it can be exercised in vitest without the Electron polyfill layer. @@ -17,10 +18,12 @@ import type { DaemonProbe } from "./daemon-attach"; /** * Reports whether something is holding the daemon port that we must kill before - * spawning. By the time it is called, the healthy-reuse attach paths have already - * returned, so any holder here is one we could not attach to: a process answering - * healthz that failed identity (probe non-null), or a hung holder that does not - * answer but whose run-file PID is still alive. + * spawning. By the time it is called, both healthy-reuse attach paths have already + * returned null. The primary trigger is holderPidAlive: the run-file names a PID + * that is still alive but is not answering /healthz (a hung/wedged holder). The + * probe disjunct is a defensive guard: a holder that answers at this point is + * unexpected (resolveDaemonFromPort already returned null), but if it does appear, + * we replace it rather than colliding on spawn. * * Returns true when the caller should kill the holder, wait for the port to * free, clear the stale run-file, then spawn a fresh daemon. From 0c46172b325e8ef5ae2c75430fdc4aa3c52000ef Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 24 Jun 2026 16:46:24 +0530 Subject: [PATCH 60/60] fix(session): restore promptless orchestrators and crash-orphaned sessions The orchestrator was abandoned on every app open: a fresh orchestrator spawned each launch and the prior conversation appeared lost (it was not; the transcript stays in ~/.claude, resumable by the deterministic --session-id AO pins). Two defects combined: 1. Restore's guard rejected any session with no agentSessionId AND no prompt as ErrNotResumable. But Claude resumes via a deterministic session id regardless of those fields, so promptless orchestrators were perfectly resumable yet always rejected. Workers slipped through only because they carry a prompt. Move the resumability decision to the adapter: restoreArgv returns ErrNotResumable only when GetRestoreCommand reports it cannot resume AND there is no prompt to fresh-launch from. 2. reconcileLive marked a crash-orphaned (dead-runtime) session terminated without a restore marker, so RestoreAll skipped it and it stayed dead. It now saves-and-tears-down to the same end state a graceful shutdown produces (capture work, write the session_worktrees marker, terminate, remove the worktree), so RestoreAll relaunches it on the same boot, resuming history. Crash recovery now matches graceful restart. If work capture fails it terminates without a marker rather than risk losing un-preserved work. Tests: promptless orchestrator restores via adapter resume; promptless session with a non-resuming adapter still returns ErrNotResumable; reconcileLive writes the marker + tears down the worktree. Full backend suite green (1632), gofmt/vet clean. Co-Authored-By: Claude Opus 4.8 --- backend/internal/session_manager/manager.go | 67 ++++++++++++++--- .../internal/session_manager/manager_test.go | 73 +++++++++++++++++++ 2 files changed, 128 insertions(+), 12 deletions(-) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 7285fe37..2fab927f 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -25,9 +25,12 @@ var ( ErrNotRestorable = errors.New("session: not restorable (not terminal)") ErrTerminated = errors.New("session: terminated") ErrIncompleteHandle = errors.New("session: incomplete teardown handle") - // ErrNotResumable means a terminated session has no saved agent session id - // and no prompt, so there is nothing for Restore to relaunch from. Distinct - // from ErrNotRestorable (which is "not terminal yet"). + // ErrNotResumable means there is nothing for Restore to relaunch from: the + // harness adapter cannot resume the session (no native or derivable session + // id) AND no prompt was saved to fresh-launch from. Resumability is decided + // by the adapter (e.g. Claude Code pins a deterministic --session-id, so it + // resumes with no captured token), not by inspecting metadata fields here. + // Distinct from ErrNotRestorable (which is "not terminal yet"). ErrNotResumable = errors.New("session: nothing to resume from") // ErrProjectNotResolvable means the spawn's project has no usable repo // (unregistered, archived, or missing a path). The API maps it to a 400. @@ -483,9 +486,10 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess if meta.WorkspacePath == "" || meta.Branch == "" { return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, ErrIncompleteHandle) } - if meta.AgentSessionID == "" && meta.Prompt == "" { - return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, ErrNotResumable) - } + // Resumability is NOT decided here: a promptless session can still be fully + // resumable when the harness pins a deterministic session id (Claude Code). + // restoreArgv asks the adapter and returns ErrNotResumable only when the + // adapter cannot resume AND there is no prompt to fresh-launch from. project, err := m.loadProject(ctx, rec.ProjectID) if err != nil { @@ -628,9 +632,16 @@ func (m *Manager) saveAndTeardownOne(ctx context.Context, rec domain.SessionReco // reconcileLive handles a single non-terminated session on boot. If its runtime // session is still alive (tmux is the persistence layer, so it survives a daemon // crash) we adopt it: a no-op, the agent keeps running. If the runtime is gone, -// the agent died with the daemon, so we capture any uncommitted work into a -// preserve ref (best-effort) and mark the session terminated. We never relaunch -// here (that is spawn policy) and never delete the worktree. +// the agent died with the daemon, so we save-and-tear-down to the SAME end state +// a graceful shutdown produces: capture uncommitted work into a preserve ref, +// record the session_worktrees restore marker, mark terminated, and remove the +// worktree. RestoreAll (which Reconcile runs immediately after) then relaunches +// it on this same boot, resuming history. Crash recovery thus matches graceful +// restart instead of silently abandoning the session. +// +// If the work capture fails we mark terminated WITHOUT a marker and leave the +// worktree intact: better to skip the relaunch than to tear down un-preserved +// work or relaunch onto an inconsistent worktree. func (m *Manager) reconcileLive(ctx context.Context, rec domain.SessionRecord) error { if rec.Metadata.WorkspacePath == "" || rec.Metadata.Branch == "" { return nil @@ -646,13 +657,39 @@ func (m *Manager) reconcileLive(ctx context.Context, rec domain.SessionRecord) e return nil // adopt: the session survived the crash. } } - // Runtime is gone: preserve work (best-effort) then mark terminated. - if _, err := m.workspace.StashUncommitted(ctx, workspaceInfo(rec)); err != nil { - m.logger.Warn("reconcile: stash uncommitted failed; marking terminated anyway", "sessionID", rec.ID, "error", err) + // Runtime is gone: capture uncommitted work first. + ws := workspaceInfo(rec) + ref, err := m.workspace.StashUncommitted(ctx, ws) + if err != nil { + // Could not capture work: do NOT write a restore marker or tear down the + // worktree (that would risk losing un-preserved work). Mark terminated so + // a dead session is not left looking live; the worktree stays put. + m.logger.Warn("reconcile: stash uncommitted failed; terminating without restore marker", "sessionID", rec.ID, "error", err) + if mErr := m.lcm.MarkTerminated(ctx, rec.ID); mErr != nil { + return fmt.Errorf("reconcile %s: mark terminated: %w", rec.ID, mErr) + } + return nil + } + // Work captured. Record the shutdown-saved marker BEFORE tearing down the + // worktree, mirroring saveAndTeardownOne, so RestoreAll relaunches it. + row := domain.SessionWorktreeRecord{ + SessionID: rec.ID, + RepoName: domain.RootWorkspaceRepoName, + Branch: rec.Metadata.Branch, + WorktreePath: rec.Metadata.WorkspacePath, + PreservedRef: ref, + } + if err := m.store.UpsertSessionWorktree(ctx, row); err != nil { + return fmt.Errorf("reconcile %s: upsert worktree marker: %w", rec.ID, err) } if err := m.lcm.MarkTerminated(ctx, rec.ID); err != nil { return fmt.Errorf("reconcile %s: mark terminated: %w", rec.ID, err) } + // Remove the worktree (work is captured in the ref): RestoreAll re-creates it + // clean and replays the ref. The dead runtime needs no Destroy. + if err := m.workspace.ForceDestroy(ctx, ws); err != nil { + m.logger.Warn("reconcile: force destroy failed after marker", "sessionID", rec.ID, "error", err) + } return nil } @@ -1195,6 +1232,12 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo if ok { return cmd, nil } + // The adapter reports no session to resume (no native or derivable session + // id). A saved prompt lets us relaunch fresh; with neither, there is + // genuinely nothing to restore from. + if meta.Prompt == "" { + return nil, ErrNotResumable + } argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{ SessionID: string(id), WorkspacePath: workspacePath, diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index a6b1d0e7..6f23a271 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -228,6 +228,15 @@ type singleAgent struct{ agent ports.Agent } func (s singleAgent) Agent(domain.AgentHarness) (ports.Agent, bool) { return s.agent, true } +// alwaysResumeAgent mimics Claude Code: it pins a deterministic session id, so +// GetRestoreCommand can resume any session even with no captured agentSessionId +// and no prompt. +type alwaysResumeAgent struct{ fakeAgent } + +func (alwaysResumeAgent) GetRestoreCommand(_ context.Context, cfg ports.RestoreConfig) ([]string, bool, error) { + return []string{"resume", cfg.Session.ID}, true, nil +} + // missingAgents resolves no harness, simulating a typo'd or unregistered agent. type missingAgents struct{} @@ -900,6 +909,54 @@ func TestRestore_FallbackLaunchCarriesSystemPrompt(t *testing.T) { } } +// TestRestore_PromptlessOrchestratorResumesViaAdapter locks the orchestrator +// fix: a promptless session with no captured agentSessionId is still restorable +// when the adapter can resume it (Claude pins a deterministic --session-id). +// Before the fix the metadata-only guard rejected it with ErrNotResumable, so +// every boot abandoned the orchestrator and spawned a fresh one. +func TestRestore_PromptlessOrchestratorResumesViaAdapter(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true, + // No AgentSessionID, no Prompt: exactly how orchestrators are persisted. + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-orchestrator"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + rt := &fakeRuntime{} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: rt, Agents: singleAgent{agent: alwaysResumeAgent{}}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) + + if _, err := m.Restore(ctx, "mer-1"); err != nil { + t.Fatalf("promptless orchestrator must restore via adapter resume, got err = %v", err) + } + if rt.created != 1 { + t.Fatalf("runtime.Create = %d, want 1 (resumed)", rt.created) + } + if st.sessions["mer-1"].IsTerminated { + t.Error("orchestrator must be live after restore") + } +} + +// TestRestore_RefusesPromptlessWhenAdapterCannotResume preserves the typed +// error: a promptless session whose adapter cannot resume (no native session id) +// has genuinely nothing to relaunch from and must still return ErrNotResumable. +func TestRestore_RefusesPromptlessWhenAdapterCannotResume(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker, IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + lookPath := func(string) (string, error) { return "/bin/true", nil } + // fakeAgents resolves to fakeAgent, whose GetRestoreCommand returns ok=false + // without an agentSessionId. + m := New(Deps{Runtime: &fakeRuntime{}, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) + + if _, err := m.Restore(ctx, "mer-1"); !errors.Is(err, ErrNotResumable) { + t.Fatalf("Restore err = %v, want ErrNotResumable", err) + } +} + // TestRestore_WorkerPointsAtCurrentOrchestrator: a restored worker's // coordination hint must reference the orchestrator active at restore time, // not the one from its original spawn. @@ -1549,6 +1606,22 @@ func TestReconcileLive_DeadSessionStashedAndTerminated(t *testing.T) { if rt.destroyed != 0 { t.Fatalf("Destroy calls = %d, want 0 (dead session: no tmux to kill)", rt.destroyed) } + // The crash-orphaned session must be saved for restore, exactly like a + // graceful shutdown: a session_worktrees marker carrying the preserve ref, + // and the worktree torn down so RestoreAll re-creates it clean. + rows := st.worktrees["s1"] + if len(rows) != 1 || rows[0].PreservedRef != "refs/ao/preserved/s1" { + t.Fatalf("session_worktrees marker for s1 = %+v, want one row with the preserve ref", rows) + } + foundForceDestroy := false + for _, c := range ws.calls { + if c == "ForceDestroy:s1" { + foundForceDestroy = true + } + } + if !foundForceDestroy { + t.Fatalf("reconcileLive must ForceDestroy the worktree after capturing work; calls = %v", ws.calls) + } } func TestReconcileLive_AliveSessionAdoptedNoop(t *testing.T) {