Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
bc23964
feat(runtime): add tmux adapter package
harshitsinghbhandari Jun 23, 2026
44c3e61
fix(tmux): address four code-review findings in tmux runtime adapter
harshitsinghbhandari Jun 23, 2026
b459abf
feat(runtime): wire tmux on Darwin/Linux via runtimeselect, keep zell…
harshitsinghbhandari Jun 23, 2026
4b21e4b
chore: tidy runtime-neutral comments and doctor import grouping
harshitsinghbhandari Jun 23, 2026
926ee31
refactor(tmux): drop unused runner.Start seam
harshitsinghbhandari Jun 23, 2026
1050542
feat(conpty): add protocol codec and output ring buffer (pure Go, OS-…
harshitsinghbhandari Jun 23, 2026
d67941b
test(conpty): harden copy-safety and add concurrent ring test
harshitsinghbhandari Jun 23, 2026
6552e26
feat(ptyregistry): port Windows pty-host sideband registry to Go
harshitsinghbhandari Jun 23, 2026
41ce417
chore(sdd): phase B briefs and progress for B1-B3
harshitsinghbhandari Jun 23, 2026
a7b052b
feat(conpty): add pty-host serve engine with loopback TCP transport (B3)
harshitsinghbhandari Jun 23, 2026
6cd6e2a
fix(conpty): deliver scrollback snapshot and register client atomically
harshitsinghbhandari Jun 23, 2026
be06609
chore(sdd): B4 brief
harshitsinghbhandari Jun 23, 2026
4aac665
feat(conpty): add runtime adapter with loopback pty-client and sessio…
harshitsinghbhandari Jun 23, 2026
0bc26e5
fix(conpty): split IsAlive dead-vs-transient for reaper safety
harshitsinghbhandari Jun 23, 2026
8d757ed
chore(sdd): B5 brief + ledger
harshitsinghbhandari Jun 23, 2026
5bfbc48
feat(terminal): stream-based Attach for tmux/zellij/conpty
harshitsinghbhandari Jun 23, 2026
1511f1a
style(ptyexec): replace em dashes carried from moved pty files
harshitsinghbhandari Jun 23, 2026
b9ef1f5
chore(sdd): B6 brief + B5 ledger
harshitsinghbhandari Jun 23, 2026
6a18394
feat(runtime): select conpty on Windows, register pty-host subcommand…
harshitsinghbhandari Jun 23, 2026
3b7ff9c
docs(daemon): correct terminal-runtime comment to conpty on Windows
harshitsinghbhandari Jun 23, 2026
284e840
docs(ptyexec): drop stale zellij reference in Windows spawn comment
harshitsinghbhandari Jun 23, 2026
25caa1f
chore(sdd): final phase B ledger
harshitsinghbhandari Jun 23, 2026
1c4ce2f
build(desktop): support local keychain signing for macOS builds
harshitsinghbhandari Jun 23, 2026
6159bdb
fix(daemon): default TERM so Finder-launched tmux attach works
harshitsinghbhandari Jun 23, 2026
8e4e6da
docs(lifecycle): plan for save-on-close/restore-on-open sessions
harshitsinghbhandari Jun 23, 2026
975a6b3
chore(frontend): sync regenerated pnpm-lock and routeTree
harshitsinghbhandari Jun 23, 2026
aeebf44
feat(workspace): add ForceDestroy for shutdown-path worktree removal
harshitsinghbhandari Jun 23, 2026
cbe6f21
feat(workspace): add StashUncommitted and ApplyPreserved for session …
harshitsinghbhandari Jun 24, 2026
243f4aa
fix(workspace): replace path-checkout with cherry-pick in ApplyPreserved
harshitsinghbhandari Jun 24, 2026
1decfde
feat(session-manager): add SaveAndTeardownAll and RestoreAll for shut…
harshitsinghbhandari Jun 24, 2026
de03bf8
fix(terminal): enable tmux mouse scroll and fix link clicking
harshitsinghbhandari Jun 24, 2026
4f13432
test(session-manager): assert UpsertSessionWorktree precedes ForceDes…
harshitsinghbhandari Jun 24, 2026
e3d52cd
feat(daemon): wire RestoreAll/SaveAndTeardownAll into boot/shutdown s…
harshitsinghbhandari Jun 24, 2026
7efabff
test(daemon): fix seam-test tautology and lifecycle variable shadow
harshitsinghbhandari Jun 24, 2026
91f6d0f
feat(frontend): call POST /shutdown before killing daemon on quit
harshitsinghbhandari Jun 24, 2026
f26dc22
fix(storage): guard session_worktrees.state against empty-string CHEC…
harshitsinghbhandari Jun 24, 2026
eeaf875
test(storage): add real-SQLite test for empty-State guard in UpsertSe…
harshitsinghbhandari Jun 24, 2026
c1ca9c0
fix(comments): correct shutdown-mechanism and task-ref inaccuracies
harshitsinghbhandari Jun 24, 2026
e29096b
Merge pull request #2 from harshitsinghbhandari/feat/session-lifecycl…
harshitsinghbhandari Jun 24, 2026
407773e
Merge pull request #1 from harshitsinghbhandari/fix/tmux-mouse-and-links
harshitsinghbhandari Jun 24, 2026
2016ee2
chore: remove .superpowers workflow scratch from repo
harshitsinghbhandari Jun 24, 2026
08e21de
docs(spec): graceful restore + post-failure orchestrator recreate
harshitsinghbhandari Jun 24, 2026
12c9489
docs(plan): restore-recreate orchestrator; reuse existing /orchestrat…
harshitsinghbhandari Jun 24, 2026
9c97ba3
fix(session): return typed SESSION_NOT_RESUMABLE instead of 500 on un…
harshitsinghbhandari Jun 24, 2026
b0923ce
feat(renderer): offer recreate-orchestrator popup when a session cann…
harshitsinghbhandari Jun 24, 2026
16ab311
docs(spec): drop stale OpenAPI-regen note (feature adds no route)
harshitsinghbhandari Jun 24, 2026
2daf6a0
Merge pull request #3 from harshitsinghbhandari/feat/restore-recreate…
harshitsinghbhandari Jun 24, 2026
e69dc92
fix(ci): gofmt/goimports, golangci-lint hygiene, and Windows-aware do…
harshitsinghbhandari Jun 24, 2026
9368a60
test(ci): set git identity in worktree clone fixture; loosen tmux rea…
harshitsinghbhandari Jun 24, 2026
9df0b2c
test(terminal): resend size probe on tmux reattach until the shell an…
harshitsinghbhandari Jun 24, 2026
00b447b
test(terminal): set TERM for real-tmux attach tests so they run in CI
harshitsinghbhandari Jun 24, 2026
20daf29
docs(spec): crash-proof session reconcile design
harshitsinghbhandari Jun 24, 2026
5801dc0
docs(spec): simplify reconcile to per-session IsAlive, drop ListSessions
harshitsinghbhandari Jun 24, 2026
24720a1
docs(plan): crash-proof session reconcile implementation plan
harshitsinghbhandari Jun 24, 2026
50314c6
feat(session): reconcile live pass (adopt alive, stash+terminate dead)
harshitsinghbhandari Jun 24, 2026
9aebda6
feat(session): reconcile reap pass and Reconcile entry point
harshitsinghbhandari Jun 24, 2026
ce03ba8
feat(daemon): run Reconcile on boot in place of bare RestoreAll
harshitsinghbhandari Jun 24, 2026
245a761
test(integration): reconcile terminates dead-live sessions and reaps …
harshitsinghbhandari Jun 24, 2026
6791cd6
test(integration): correct misleading CreateSession comment in reconc…
harshitsinghbhandari Jun 24, 2026
284404f
feat(frontend): kill+replace a wedged orphan daemon on launch
harshitsinghbhandari Jun 24, 2026
4d8222c
fix(frontend): fire orphan-daemon takeover when a holder actually exists
harshitsinghbhandari Jun 24, 2026
711de8f
docs+test: accurate takeover comments, reconcileLive probe-error test…
harshitsinghbhandari Jun 24, 2026
0c46172
fix(session): restore promptless orchestrators and crash-orphaned ses…
harshitsinghbhandari Jun 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,6 @@ builder-debug.yml

# playwright artifacts
frontend/test-results/

# built daemon binary copied into the frontend bundle dir
frontend/daemon/
124 changes: 124 additions & 0 deletions backend/internal/adapters/runtime/conpty/attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// 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) {
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
}

func (s *loopbackStream) Resize(rows, cols uint16) error {
payload, _ := json.Marshal(ResizePayload{Cols: int(cols), Rows: int(rows)})
frame, err := EncodeMessage(MsgResize, payload) // small JSON payload, never overflows uint32
if err != nil {
return err
}
_, err = s.conn.Write(frame)
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
}
151 changes: 151 additions & 0 deletions backend/internal/adapters/runtime/conpty/attach_test.go
Original file line number Diff line number Diff line change
@@ -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}
}
Loading
Loading