Let task agents read and type into the task's terminal

This commit is contained in:
Ian Gulliver
2026-10-18 15:05:28 -07:00
parent 2658dcbfc4
commit 1ef3594016
8 changed files with 480 additions and 201 deletions
+5 -2
View File
@@ -5,7 +5,8 @@
- `cmd/byre`: `byre serve` runs the server; `byre run` drives one Claude session in the current directory from the terminal.
- `api/openapi.yaml` is the API source of truth. `go generate ./internal/api` regenerates `internal/api/api.gen.go`, whose types are also the store's model types.
- `internal/store`: SQLite (WAL). Every mutation a client sees commits in one transaction with its event, and events are published to SSE subscribers in `seq` order. Internal bookkeeping writes commit with no event of their own: user rows from `EnsureUser`, blob rows, a merge's `finalizing_commit`, `finalizing_notes` and `finalizing_unchanged`, and the `review_requests` counter, whose effect shows only through the `task.state` it can emit.
- `internal/server`: HTTP handlers, identity, SSE, the worktree file browser (see "Files"), terminals (see "Terminals").
- `internal/server`: HTTP handlers, identity, SSE, the worktree file browser (see "Files"), the terminal WebSocket (see "Terminals").
- `internal/terminal`: each task's shell on a PTY, shared by the terminal WebSocket and the agents' terminal tools (see "Terminals").
- `internal/git`: worktrees, the task changes view (see "Changes"), snapshots (see "Snapshots"), unmerged-change summaries, squash merges, merge notes and pushes.
- `internal/agents`: the agent session manager. It owns the running agent processes, task agents, lead agents (see "Lead agent") and reviewers (see "Reviews") alike, and records their activity as events.
- `internal/claude`: the Claude Code stream-json client (see `docs/claude-protocol.md`).
@@ -348,7 +349,9 @@ The view shows a breadcrumb from `worktree` down to the current directory or fil
## Terminals
Each active task can have one terminal: a login shell (`$SHELL -l`, from byre's environment) on a PTY (`github.com/creack/pty`), started in the task's worktree with byre's environment plus `TERM=xterm-256color`, `COLORTERM=truecolor` and the task's `PORT` (`internal/server/terminal.go`). `GET /api/tasks/{taskId}/terminal?cols=&rows=` is a WebSocket (`github.com/coder/websocket`), the only WebSocket in byre: it attaches to the task's terminal, starting the shell at that size if none is running, and works only while the task is active (409 otherwise). The server first sends the last `terminalBacklog` bytes the terminal printed, then its output as it comes, as binary messages; the client sends keystrokes as binary messages and a `TerminalSize` JSON text message on every resize. Any number of sockets can attach to one terminal: they all get its output and all type into it, and the last resize wins. A socket that falls `terminalClientBuf` reads behind is closed and the app reconnects. When the shell exits, every socket is closed with code 4000 and the exit status (`exit status 0`, `signal: hangup`) as the reason, and the next attach starts a new shell. Merging or discarding the task, and stopping byre, sends the shell's process group SIGHUP and closes the PTY, so terminals don't survive a restart. The WebSocket handshake checks that `Origin` matches `Host`, so another site can't open one through the user's Tailscale identity. The log records `terminal started` and `terminal ended` with the task, pid and exit status.
Each active task can have one terminal: a login shell (`$SHELL -l`, from byre's environment) on a PTY (`github.com/creack/pty`), started in the task's worktree with byre's environment plus `TERM=xterm-256color`, `COLORTERM=truecolor` and the task's `PORT` (`internal/terminal`, which the server creates and hands `agents.New`). `GET /api/tasks/{taskId}/terminal?cols=&rows=` is a WebSocket (`github.com/coder/websocket`, `internal/server/terminal.go`), the only WebSocket in byre: it attaches to the task's terminal, starting the shell at that size if none is running and resizing it to that size if one is, and works only while the task is active (409 otherwise). The server first sends the last `terminalBacklog` bytes the terminal printed, then its output as it comes, as binary messages; the client sends keystrokes as binary messages and a `TerminalSize` JSON text message on every resize. Any number of sockets can attach to one terminal: they all get its output and all type into it, and the last resize wins. A socket that falls `terminalClientBuf` reads behind is closed and the app reconnects. When the shell exits, every socket is closed with code 4000 and the exit status (`exit status 0`, `signal: hangup`) as the reason, and the next attach starts a new shell. Merging or discarding the task, and stopping byre, sends the shell's process group SIGHUP and closes the PTY, so terminals don't survive a restart. The WebSocket handshake checks that `Origin` matches `Host`, so another site can't open one through the user's Tailscale identity. The log records `terminal started` and `terminal ended` with the task, pid and exit status.
A task's agents use the same terminal through two MCP tools (`internal/agents/terminal.go`). `terminal_read` returns the backlog as plain text; it starts no shell, and says so when none is running. `terminal_type` writes `text` to the PTY as keystrokes, starting the shell at 120×40 if none is running and leaving a running one's size alone (refused once the task is closed). It then returns, as plain text, the output that followed, once none has arrived for 500ms or after 10s, whichever comes first, holding at most the last 512 KiB of raw output while it waits. It types on its own goroutine one byte per write and stops between bytes once the call returns. A program in raw mode that stops reading blocks a PTY write, and the PTY's file doesn't support deadlines, so typing one byte at a time keeps the call within its limits while leaving at most one byte to arrive late. The reply then says how many bytes were typed. Typing from an agent doesn't count as the human acting (`last_action_at`). The plain text comes from `terminal.Text` (`internal/terminal/text.go`). It replays the output one line at a time, keeping track of the cursor column, so a carriage return overwrites the line (progress bars, zsh's `\r\r\n` after a command) instead of duplicating it, and a backspace moves the cursor back. It applies erase-to-end-of-line (`CSI K`) and drops every other escape sequence and control character. It keeps the last 64 KiB, cut at a line start. Full-screen programs come out garbled, since it isn't a screen emulator.
The **Terminal** tab (`mac/Sources/Byre/Terminal.swift`) draws it with SwiftTerm's `TerminalView`. The terminal uses the UI font and is coloured from the palette (background, text, accent cursor, accent-tint selection); ANSI colours are SwiftTerm's defaults. The colours are set once per light/dark scheme (`TerminalSession.scheme`), never on other SwiftUI updates, since each colour set makes SwiftTerm redraw the whole screen and the tab re-renders on every task change in the store. It fits the pane and resizes with it. Opening the tab, or selecting another task while it is open, opens one socket for the selected task; switching tab or task closes it, and the shell keeps running, so coming back replays its recent output. A socket that drops for any other reason reconnects after 2s while the task is active; after a byre restart that starts a new shell. When the shell exits the header shows `shell ended: <status>` and a **New shell** button; when it can't be started (the socket closes with 1011, the error in byre's log as `start terminal`), the header says so, with the same button to try again. Ctrl key combinations typed in the terminal go to the shell, not to byre's Ctrl-K, Ctrl-P, Ctrl-L and Ctrl-S shortcuts; ⌘K, ⌘P, ⌘L and ⌘S still work.
+1 -1
View File
@@ -32,7 +32,7 @@ var (
quoted = regexp.MustCompile(`"[^"\n]*"|“[^”\n]*”`)
blockquote = regexp.MustCompile(`(?m)^\s*>.*$`)
tableRow = regexp.MustCompile(`(?m)^\s*\|.*$`)
details = regexp.MustCompile(`(?s)<details>.*?</details>`)
details = regexp.MustCompile(`(?s)<details>.*?</details>`)
paragraphs = regexp.MustCompile(`\n\s*\n`)
endsQuestion = regexp.MustCompile(`\?[*_)\]]*(\s|$)`)
)
+5 -2
View File
@@ -20,6 +20,7 @@ import (
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/internal/terminal"
"g.fc.run/misc/byre/internal/workflow"
)
@@ -42,6 +43,7 @@ type Manager struct {
status func(context.Context) (api.Status, error)
addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error)
clients *clients.Registry
terminals *terminal.Terminals
mu sync.Mutex
live map[string]*live
@@ -123,7 +125,7 @@ func (l *live) implementer() bool {
return l.owner == "" && l.reviewID == ""
}
func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome string, status func(context.Context) (api.Status, error), addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error), clientRegistry *clients.Registry) *Manager {
func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome string, status func(context.Context) (api.Status, error), addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error), clientRegistry *clients.Registry, terminals *terminal.Terminals) *Manager {
m := &Manager{
store: st,
worktreeRoot: worktreeRoot,
@@ -133,6 +135,7 @@ func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome string, s
status: status,
addProject: addProject,
clients: clientRegistry,
terminals: terminals,
live: map[string]*live{},
starting: map[string]*start{},
deliverLocks: map[string]*sessionLock{},
@@ -732,7 +735,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
if err != nil {
return nil, err
}
tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l)}, append(m.subtaskTools(l), m.clientTools(l)...)...)
tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l), m.terminalReadTool(l), m.terminalTypeTool(l)}, append(m.subtaskTools(l), m.clientTools(l)...)...)
lead := true
if sess.Role == api.Implementer {
g += longTitleGuidance(task)
+150
View File
@@ -0,0 +1,150 @@
package agents
import (
"bytes"
"context"
"encoding/json"
"fmt"
"sync/atomic"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/terminal"
)
const (
typeQuiet = 500 * time.Millisecond
typeMaxWait = 10 * time.Second
typeKeep = 256 << 10
)
var agentTerminalSize = api.TerminalSize{Cols: 120, Rows: 40}
func (m *Manager) terminalReadTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "terminal_read",
Description: "Read your task's terminal: the shell in this task's worktree that the human uses from the Terminal tab, and that terminal_type types into. " +
"Returns its recent output as plain text, escape sequences removed, at most the last 64 KiB. " +
"Full-screen programs (vim, top, less) come out garbled, since this is the output stream, not a picture of the screen.",
InputSchema: json.RawMessage(`{"type": "object", "properties": {}}`),
Handler: func(ctx context.Context, args json.RawMessage) (string, error) {
term := m.terminals.Get(l.taskID)
if term == nil {
return "No shell is running in this task's terminal.", nil
}
out := terminal.Text(term.Backlog())
exit, exited := term.ExitStatus()
if exited {
out += "\n[shell ended: " + exit + "]"
}
return out, nil
},
}
}
type terminalTypeArgs struct {
Text string `json:"text"`
}
func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "terminal_type",
Description: "Type into your task's terminal: the shell in this task's worktree that the human uses from the Terminal tab, starting it when none is running. " +
"text goes to the shell exactly as keystrokes, so end a command with \"\\n\" to run it, and send control characters for keys (\"\\u0003\" is Ctrl-C). " +
"The human sees everything you type and its output. " +
"Returns the output that followed, as terminal_read does, once the terminal has been quiet for half a second or after 10 seconds; read the rest of a longer command's output with terminal_read.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"text": {"type": "string", "description": "The keystrokes to send."}
},
"required": ["text"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
args := terminalTypeArgs{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed terminal_type call: %w", err)
}
task, err := m.store.GetTask(ctx, l.taskID)
if err != nil {
return "", err
}
if task.Status != api.TaskStatusActive {
return "", fmt.Errorf("task %s is %s", task.Id, task.Status)
}
term, err := m.terminals.Attach(task, agentTerminalSize)
if err != nil {
return "", err
}
return typeAndWait(ctx, term, args.Text)
},
}
}
func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (string, error) {
_, out := term.Subscribe()
if out == nil {
exit, _ := term.ExitStatus()
return "", fmt.Errorf("the shell ended (%s); type again to start a new one", exit)
}
defer term.Unsubscribe(out)
stop := atomic.Bool{}
defer stop.Store(true)
typed := atomic.Int64{}
written := make(chan error, 1)
go func() {
// One byte per write: a program that stops reading blocks a write, and the PTY can't be given a deadline, so this keeps what arrives after we give up to one byte.
for i := 0; i < len(text) && !stop.Load(); i++ {
err := term.Write([]byte{text[i]})
if err != nil {
written <- err
return
}
typed.Add(1)
}
written <- nil
}()
got := []byte{}
result := func() string {
res := terminal.Text(got)
exit, exited := term.ExitStatus()
if exited {
res += "\n[shell ended: " + exit + "]"
}
if n := typed.Load(); n < int64(len(text)) {
res += fmt.Sprintf("\n[only %d of %d bytes were typed: the terminal stopped reading input]", n, len(text))
}
return res
}
deadline := time.After(typeMaxWait)
quiet := time.NewTimer(typeQuiet)
defer quiet.Stop()
for {
select {
case <-ctx.Done():
return "", ctx.Err()
case err := <-written:
if err != nil {
return "", err
}
written = nil
case <-deadline:
return result(), nil
case <-quiet.C:
return result(), nil
case b, open := <-out:
if !open {
return result(), nil
}
got = append(got, b...)
if len(got) > 2*typeKeep {
got = bytes.Clone(got[len(got)-typeKeep:])
}
quiet.Reset(typeQuiet)
}
}
}
+6 -5
View File
@@ -19,6 +19,7 @@ import (
"g.fc.run/misc/byre/internal/clients"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/internal/terminal"
"g.fc.run/misc/byre/internal/todo"
"g.fc.run/misc/byre/internal/workflow"
)
@@ -28,7 +29,7 @@ type Server struct {
agents *agents.Manager
hub *hub
clients *clients.Registry
terminals *terminals
terminals *terminal.Terminals
startedAt time.Time
usersMu sync.Mutex
@@ -45,7 +46,7 @@ func New(dataDir string, portFirst int) (*Server, error) {
s := &Server{
hub: newHub(),
terminals: newTerminals(),
terminals: terminal.New(),
startedAt: time.Now().UTC(),
users: map[string]string{},
}
@@ -68,7 +69,7 @@ func New(dataDir string, portFirst int) (*Server, error) {
}
}
s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "hands"), filepath.Join(dataDir, "codex"), s.status, s.createProject, s.clients)
s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "hands"), filepath.Join(dataDir, "codex"), s.status, s.createProject, s.clients, s.terminals)
err = s.agents.Recover(context.Background())
if err != nil {
s.store.Close()
@@ -86,14 +87,14 @@ func (s *Server) publish(events []api.Event) {
s.agents.Observe(events)
for _, ev := range events {
if (ev.Kind == "task.merged" || ev.Kind == "task.discarded") && ev.TaskId != nil {
s.terminals.close(*ev.TaskId)
s.terminals.Close(*ev.TaskId)
go s.agents.Unexpose(context.Background(), *ev.TaskId)
}
}
}
func (s *Server) Close() error {
s.terminals.closeAll()
s.terminals.CloseAll()
s.agents.Close()
return s.store.Close()
}
+13 -191
View File
@@ -1,207 +1,31 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"sync"
"syscall"
"github.com/coder/websocket"
"github.com/creack/pty"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/terminal"
)
const (
terminalBacklog = 256 << 10
terminalClientBuf = 256
terminalReadLimit = 1 << 20
terminalMaxSize = 1 << 15
terminalExited = websocket.StatusCode(4000)
)
type terminals struct {
mu sync.Mutex
byTask map[string]*terminal
}
type terminal struct {
ptmx *os.File
cmd *exec.Cmd
mu sync.Mutex
backlog []byte
clients map[chan []byte]bool
exit string
exited bool
}
func newTerminals() *terminals {
return &terminals{byTask: map[string]*terminal{}}
}
func (ts *terminals) attach(t api.Task, size api.TerminalSize) (*terminal, error) {
ts.mu.Lock()
defer ts.mu.Unlock()
term := ts.byTask[t.Id]
if term != nil {
return term, nil
}
shell := os.Getenv("SHELL")
if shell == "" {
return nil, errors.New("SHELL is not set in byre's environment")
}
cmd := exec.Command(shell, "-l")
cmd.Dir = t.WorktreePath
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", fmt.Sprintf("PORT=%d", t.PortBase))
ptmx, err := pty.StartWithSize(cmd, winsize(size))
if err != nil {
return nil, fmt.Errorf("start %s: %w", shell, err)
}
term = &terminal{ptmx: ptmx, cmd: cmd, clients: map[chan []byte]bool{}}
ts.byTask[t.Id] = term
slog.Info("terminal started", "task", t.Id, "shell", shell, "pid", cmd.Process.Pid)
go func() {
term.pump()
ts.mu.Lock()
if ts.byTask[t.Id] == term {
delete(ts.byTask, t.Id)
}
ts.mu.Unlock()
slog.Info("terminal ended", "task", t.Id, "exit", term.exit)
}()
return term, nil
}
func (ts *terminals) close(taskID string) {
ts.mu.Lock()
term := ts.byTask[taskID]
delete(ts.byTask, taskID)
ts.mu.Unlock()
if term != nil {
term.kill()
}
}
func (ts *terminals) closeAll() {
ts.mu.Lock()
all := ts.byTask
ts.byTask = map[string]*terminal{}
ts.mu.Unlock()
for _, term := range all {
term.kill()
}
}
func winsize(size api.TerminalSize) *pty.Winsize {
return &pty.Winsize{Cols: uint16(size.Cols), Rows: uint16(size.Rows)}
}
func validSize(size api.TerminalSize) bool {
return size.Cols >= 1 && size.Rows >= 1 && size.Cols <= terminalMaxSize && size.Rows <= terminalMaxSize
}
func (term *terminal) pump() {
buf := make([]byte, 32<<10)
for {
n, err := term.ptmx.Read(buf)
if n > 0 {
term.output(bytes.Clone(buf[:n]))
}
if err != nil {
break
}
}
err := term.cmd.Wait()
term.ptmx.Close()
exit := fmt.Sprint(err)
if term.cmd.ProcessState != nil {
exit = term.cmd.ProcessState.String()
}
term.mu.Lock()
defer term.mu.Unlock()
term.exit = exit
term.exited = true
for c := range term.clients {
close(c)
}
term.clients = nil
}
func (term *terminal) output(b []byte) {
term.mu.Lock()
defer term.mu.Unlock()
term.backlog = append(term.backlog, b...)
if len(term.backlog) > 2*terminalBacklog {
term.backlog = bytes.Clone(term.backlog[len(term.backlog)-terminalBacklog:])
}
for c := range term.clients {
select {
case c <- b:
default:
delete(term.clients, c)
close(c)
}
}
}
func (term *terminal) subscribe() ([]byte, chan []byte) {
term.mu.Lock()
defer term.mu.Unlock()
if term.exited {
return nil, nil
}
c := make(chan []byte, terminalClientBuf)
term.clients[c] = true
return bytes.Clone(term.backlog[max(0, len(term.backlog)-terminalBacklog):]), c
}
func (term *terminal) unsubscribe(c chan []byte) {
term.mu.Lock()
defer term.mu.Unlock()
if term.clients[c] {
delete(term.clients, c)
close(c)
}
}
func (term *terminal) exitStatus() (string, bool) {
term.mu.Lock()
defer term.mu.Unlock()
return term.exit, term.exited
}
func (term *terminal) kill() {
err := syscall.Kill(-term.cmd.Process.Pid, syscall.SIGHUP)
if err != nil && !errors.Is(err, syscall.ESRCH) {
slog.Warn("hang up terminal", "pid", term.cmd.Process.Pid, "err", err)
}
term.ptmx.Close()
}
func (s *Server) GetTaskTerminal(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskTerminalParams) {
t, _, ok := s.activeTask(w, r, taskID)
if !ok {
return
}
size := api.TerminalSize{Cols: params.Cols, Rows: params.Rows}
if !validSize(size) {
writeError(w, http.StatusBadRequest, fmt.Errorf("cols and rows must be between 1 and %d", terminalMaxSize))
if !terminal.ValidSize(size) {
writeError(w, http.StatusBadRequest, fmt.Errorf("cols and rows must be between 1 and %d", terminal.MaxSize))
return
}
@@ -213,20 +37,21 @@ func (s *Server) GetTaskTerminal(w http.ResponseWriter, r *http.Request, taskID
defer conn.CloseNow()
conn.SetReadLimit(terminalReadLimit)
term, err := s.terminals.attach(t, size)
term, err := s.terminals.Attach(t, size)
if err != nil {
slog.Error("start terminal", "task", taskID, "err", err)
conn.Close(websocket.StatusInternalError, "couldn't start the shell; see byre's log")
return
}
term.Resize(size)
backlog, out := term.subscribe()
backlog, out := term.Subscribe()
if out == nil {
exit, _ := term.exitStatus()
exit, _ := term.ExitStatus()
conn.Close(terminalExited, exit)
return
}
defer term.unsubscribe(out)
defer term.Unsubscribe(out)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
@@ -253,7 +78,7 @@ func (s *Server) GetTaskTerminal(w http.ResponseWriter, r *http.Request, taskID
}
continue
}
exit, exited := term.exitStatus()
exit, exited := term.ExitStatus()
if exited {
conn.Close(terminalExited, exit)
return
@@ -264,7 +89,7 @@ func (s *Server) GetTaskTerminal(w http.ResponseWriter, r *http.Request, taskID
}
}
func terminalInput(ctx context.Context, conn *websocket.Conn, term *terminal, acted func()) {
func terminalInput(ctx context.Context, conn *websocket.Conn, term *terminal.Terminal, acted func()) {
for {
typ, b, err := conn.Read(ctx)
if err != nil {
@@ -272,7 +97,7 @@ func terminalInput(ctx context.Context, conn *websocket.Conn, term *terminal, ac
}
if typ == websocket.MessageBinary {
acted()
_, err = term.ptmx.Write(b)
err = term.Write(b)
if err != nil {
slog.Debug("terminal input", "err", err)
}
@@ -280,13 +105,10 @@ func terminalInput(ctx context.Context, conn *websocket.Conn, term *terminal, ac
}
size := api.TerminalSize{}
err = json.Unmarshal(b, &size)
if err != nil || !validSize(size) {
if err != nil || !terminal.ValidSize(size) {
conn.Close(websocket.StatusUnsupportedData, "resize must be a TerminalSize")
return
}
err = pty.Setsize(term.ptmx, winsize(size))
if err != nil {
slog.Debug("terminal resize", "err", err)
}
term.Resize(size)
}
}
+213
View File
@@ -0,0 +1,213 @@
package terminal
import (
"bytes"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"sync"
"syscall"
"github.com/creack/pty"
"g.fc.run/misc/byre/internal/api"
)
const (
backlogSize = 256 << 10
clientBuf = 256
MaxSize = 1 << 15
)
type Terminals struct {
mu sync.Mutex
byTask map[string]*Terminal
}
type Terminal struct {
ptmx *os.File
cmd *exec.Cmd
mu sync.Mutex
backlog []byte
clients map[chan []byte]bool
exit string
exited bool
}
func New() *Terminals {
return &Terminals{byTask: map[string]*Terminal{}}
}
func (ts *Terminals) Get(taskID string) *Terminal {
ts.mu.Lock()
defer ts.mu.Unlock()
return ts.byTask[taskID]
}
func (ts *Terminals) Attach(t api.Task, size api.TerminalSize) (*Terminal, error) {
ts.mu.Lock()
defer ts.mu.Unlock()
term := ts.byTask[t.Id]
if term != nil {
return term, nil
}
shell := os.Getenv("SHELL")
if shell == "" {
return nil, errors.New("SHELL is not set in byre's environment")
}
cmd := exec.Command(shell, "-l")
cmd.Dir = t.WorktreePath
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", fmt.Sprintf("PORT=%d", t.PortBase))
ptmx, err := pty.StartWithSize(cmd, winsize(size))
if err != nil {
return nil, fmt.Errorf("start %s: %w", shell, err)
}
term = &Terminal{ptmx: ptmx, cmd: cmd, clients: map[chan []byte]bool{}}
ts.byTask[t.Id] = term
slog.Info("terminal started", "task", t.Id, "shell", shell, "pid", cmd.Process.Pid)
go func() {
term.pump()
ts.mu.Lock()
if ts.byTask[t.Id] == term {
delete(ts.byTask, t.Id)
}
ts.mu.Unlock()
slog.Info("terminal ended", "task", t.Id, "exit", term.exit)
}()
return term, nil
}
func (ts *Terminals) Close(taskID string) {
ts.mu.Lock()
term := ts.byTask[taskID]
delete(ts.byTask, taskID)
ts.mu.Unlock()
if term != nil {
term.kill()
}
}
func (ts *Terminals) CloseAll() {
ts.mu.Lock()
all := ts.byTask
ts.byTask = map[string]*Terminal{}
ts.mu.Unlock()
for _, term := range all {
term.kill()
}
}
func winsize(size api.TerminalSize) *pty.Winsize {
return &pty.Winsize{Cols: uint16(size.Cols), Rows: uint16(size.Rows)}
}
func ValidSize(size api.TerminalSize) bool {
return size.Cols >= 1 && size.Rows >= 1 && size.Cols <= MaxSize && size.Rows <= MaxSize
}
func (term *Terminal) pump() {
buf := make([]byte, 32<<10)
for {
n, err := term.ptmx.Read(buf)
if n > 0 {
term.output(bytes.Clone(buf[:n]))
}
if err != nil {
break
}
}
err := term.cmd.Wait()
term.ptmx.Close()
exit := fmt.Sprint(err)
if term.cmd.ProcessState != nil {
exit = term.cmd.ProcessState.String()
}
term.mu.Lock()
defer term.mu.Unlock()
term.exit = exit
term.exited = true
for c := range term.clients {
close(c)
}
term.clients = nil
}
func (term *Terminal) output(b []byte) {
term.mu.Lock()
defer term.mu.Unlock()
term.backlog = append(term.backlog, b...)
if len(term.backlog) > 2*backlogSize {
term.backlog = bytes.Clone(term.backlog[len(term.backlog)-backlogSize:])
}
for c := range term.clients {
select {
case c <- b:
default:
delete(term.clients, c)
close(c)
}
}
}
func (term *Terminal) Backlog() []byte {
term.mu.Lock()
defer term.mu.Unlock()
return bytes.Clone(term.backlog[max(0, len(term.backlog)-backlogSize):])
}
func (term *Terminal) Subscribe() ([]byte, chan []byte) {
term.mu.Lock()
defer term.mu.Unlock()
if term.exited {
return nil, nil
}
c := make(chan []byte, clientBuf)
term.clients[c] = true
return bytes.Clone(term.backlog[max(0, len(term.backlog)-backlogSize):]), c
}
func (term *Terminal) Unsubscribe(c chan []byte) {
term.mu.Lock()
defer term.mu.Unlock()
if term.clients[c] {
delete(term.clients, c)
close(c)
}
}
func (term *Terminal) ExitStatus() (string, bool) {
term.mu.Lock()
defer term.mu.Unlock()
return term.exit, term.exited
}
func (term *Terminal) Write(b []byte) error {
_, err := term.ptmx.Write(b)
return err
}
func (term *Terminal) Resize(size api.TerminalSize) {
err := pty.Setsize(term.ptmx, winsize(size))
if err != nil {
slog.Debug("terminal resize", "err", err)
}
}
func (term *Terminal) kill() {
err := syscall.Kill(-term.cmd.Process.Pid, syscall.SIGHUP)
if err != nil && !errors.Is(err, syscall.ESRCH) {
slog.Warn("hang up terminal", "pid", term.cmd.Process.Pid, "err", err)
}
term.ptmx.Close()
}
+87
View File
@@ -0,0 +1,87 @@
package terminal
import (
"strings"
"unicode/utf8"
)
const textLimit = 64 << 10
func Text(b []byte) string {
out := strings.Builder{}
line := []rune{}
col := 0
flush := func() {
out.WriteString(strings.TrimRight(string(line), " "))
out.WriteByte('\n')
line = line[:0]
col = 0
}
for i := 0; i < len(b); {
c := b[i]
switch {
case c == 0x1b:
end, final := skipEscape(b, i)
if final == 'K' {
line = line[:min(col, len(line))]
}
i = end + 1
continue
case c == '\n':
flush()
case c == '\r':
col = 0
case c == '\b':
col = max(0, col-1)
case c == '\t' || c >= 0x20 && c != 0x7f:
r, n := utf8.DecodeRune(b[i:])
for len(line) < col {
line = append(line, ' ')
}
if col < len(line) {
line[col] = r
} else {
line = append(line, r)
}
col++
i += n
continue
}
i++
}
s := out.String() + strings.TrimRight(string(line), " ")
if len(s) > textLimit {
s = s[len(s)-textLimit:]
s = s[strings.IndexByte(s, '\n')+1:]
}
return s
}
func skipEscape(b []byte, i int) (int, byte) {
if i+1 >= len(b) {
return i, 0
}
i++
switch b[i] {
case '[':
for i++; i < len(b); i++ {
if b[i] >= 0x40 && b[i] <= 0x7e {
return i, b[i]
}
}
return len(b) - 1, 0
case ']', 'P', '_', '^':
for i++; i < len(b); i++ {
if b[i] == 0x07 {
return i, 0
}
if b[i] == 0x1b && i+1 < len(b) && b[i+1] == '\\' {
return i + 1, 0
}
}
return len(b) - 1, 0
case '(', ')', '*', '+', '#', '%':
return min(i+1, len(b)-1), 0
}
return i, 0
}