Sync MCP surface to SPEC §7 process model
Rename list_children/read_output/kill/send_message_to to their SPEC §7 process_id-shaped names; drop report_to_parent (direction inferred by send_message) and policy_check (replaced by per-project trust gating). Add the SPEC's missing tools: start_process, restart_process, close_process, rename_process, select_process, get_process_status, get_project_status, get_process_raw_output, search_output, get_process_ports, whoami, help. Process model now distinguishes agent/terminal/command kinds with opaque p_<6hex> IDs. Command entries are session-persistent so they survive PTY exit and can be Restart'd. Status enum gains starting and stopped. screen_version, port detection, and bracketed-paste send_input land alongside. Trust gating (internal/trust) replaces the regex policy: command-preset spawns return needs_trust on first use; the user confirms in a status-line modal and the grant persists to \$XDG_DATA_HOME/patterm/projects/<key>/trust.json. Tests cover send_message direction inference (parent↔child, sibling rejection, nil caller paths) and trust grant persistence across reopen.
This commit is contained in:
@@ -16,9 +16,9 @@ import (
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/harrybrwn/patterm/internal/mcp"
|
||||
"github.com/harrybrwn/patterm/internal/policy"
|
||||
"github.com/harrybrwn/patterm/internal/preset"
|
||||
"github.com/harrybrwn/patterm/internal/scratchpad"
|
||||
"github.com/harrybrwn/patterm/internal/trust"
|
||||
)
|
||||
|
||||
// Options configures a patterm run.
|
||||
@@ -41,11 +41,6 @@ func Run(ctx context.Context, opts Options) error {
|
||||
return fmt.Errorf("app: load presets: %w", err)
|
||||
}
|
||||
|
||||
pol, err := policy.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: load policy: %w", err)
|
||||
}
|
||||
|
||||
// Ensure the per-project scratchpad dir exists so MCP and the UI
|
||||
// can read/write into it. SPEC §3.
|
||||
pads, err := scratchpad.Open(opts.ProjectKey)
|
||||
@@ -53,6 +48,12 @@ func Run(ctx context.Context, opts Options) error {
|
||||
return fmt.Errorf("app: scratchpad init: %w", err)
|
||||
}
|
||||
|
||||
// Per-project trust store for command-preset trust gating (SPEC §7).
|
||||
trustStore, err := trust.Open(opts.ProjectKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: trust init: %w", err)
|
||||
}
|
||||
|
||||
// In-process MCP server bound to the per-PID socket. Children that
|
||||
// support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`.
|
||||
// SPEC §10.
|
||||
@@ -76,7 +77,7 @@ func Run(ctx context.Context, opts Options) error {
|
||||
// Wire the tool host into MCP. Spawns through MCP use the host
|
||||
// terminal's viewport grid for their initial PTY size; SIGWINCH paths
|
||||
// resize them later.
|
||||
host := newToolHost(sess, pads, launcher, presets, pol, layout.childCols(), layout.childRows())
|
||||
host := newToolHost(sess, pads, launcher, presets, trustStore, layout.childCols(), layout.childRows())
|
||||
mcpSrv.SetHost(host)
|
||||
|
||||
var restoreState *term.State
|
||||
@@ -96,11 +97,14 @@ func Run(ctx context.Context, opts Options) error {
|
||||
presets: presets,
|
||||
launcher: launcher,
|
||||
pads: pads,
|
||||
trust: trustStore,
|
||||
hostCols: cols,
|
||||
hostRows: rows,
|
||||
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
}
|
||||
host.attention = st
|
||||
host.focus = st
|
||||
host.prompter = st
|
||||
st.lastExit.Store(-1)
|
||||
sess.Subscribe(st)
|
||||
|
||||
@@ -200,6 +204,7 @@ type uiState struct {
|
||||
presets preset.Set
|
||||
launcher *Launcher
|
||||
pads *scratchpad.Store
|
||||
trust *trust.Store
|
||||
|
||||
outMu sync.Mutex
|
||||
|
||||
@@ -220,6 +225,13 @@ type uiState struct {
|
||||
attentionText string
|
||||
attentionAt string
|
||||
|
||||
// pendingTrust is the most recent trust prompt — surfaced in the
|
||||
// status line until the user resolves it with Ctrl-K. v1 keeps the
|
||||
// confirmation modal minimal: the user opens the palette and picks
|
||||
// "Trust preset <name>" / "Deny preset <name>". A future iteration
|
||||
// can promote this to a dedicated inline modal.
|
||||
pendingTrust *trustRequest
|
||||
|
||||
dimsMu sync.Mutex
|
||||
hostCols, hostRows uint16
|
||||
stdinTTY bool
|
||||
@@ -231,6 +243,42 @@ func (st *uiState) dbgf(format string, args ...any) {
|
||||
logf(format, args...)
|
||||
}
|
||||
|
||||
// trustRequest is one outstanding SPEC §7 trust prompt: an agent tried
|
||||
// to spawn / start / restart against an untrusted command preset and
|
||||
// the host wants user confirmation before the next attempt succeeds.
|
||||
type trustRequest struct {
|
||||
processID string
|
||||
presetName string
|
||||
reason string
|
||||
}
|
||||
|
||||
// promptTrust is the SPEC §7 trust gate UI hook. Replaces any prior
|
||||
// pending request — the most recent prompt wins.
|
||||
func (st *uiState) promptTrust(processID, presetName, reason string) {
|
||||
st.mu.Lock()
|
||||
st.pendingTrust = &trustRequest{processID: processID, presetName: presetName, reason: reason}
|
||||
st.mu.Unlock()
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// focusProcess is the SPEC §7 select_process hook. Routes through the
|
||||
// normal focus-change path; only takes effect if the process exists.
|
||||
func (st *uiState) focusProcess(processID string) {
|
||||
c := st.sess.FindChild(processID)
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.focusedID = c.ID
|
||||
st.focusedName = c.DisplayName()
|
||||
st.renderer = newViewportRenderer(st.layoutSnapshot())
|
||||
st.mu.Unlock()
|
||||
st.repaintFocused()
|
||||
st.drawTabBar()
|
||||
st.drawSidebar()
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// notifyAttention is the request_human_attention sink (SPEC §7). We
|
||||
// surface a one-line toast in the status row and remember the most
|
||||
// recent ask so the status line keeps showing it. The sidebar-blink is
|
||||
@@ -370,6 +418,10 @@ func (st *uiState) drawStatusLine() {
|
||||
focusName := st.focusedName
|
||||
attention := st.attentionText
|
||||
attentionAt := st.attentionAt
|
||||
var trustMsg string
|
||||
if st.pendingTrust != nil {
|
||||
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
|
||||
}
|
||||
st.mu.Unlock()
|
||||
if palOpen {
|
||||
return
|
||||
@@ -403,6 +455,13 @@ func (st *uiState) drawStatusLine() {
|
||||
if attention != "" && attentionAt == focusID {
|
||||
left = "[!] " + attention
|
||||
}
|
||||
if attention != "" && attentionAt == "" {
|
||||
// Sticky attention/flash from somewhere outside the focused pane.
|
||||
left = "[!] " + attention
|
||||
}
|
||||
if trustMsg != "" {
|
||||
left = "[trust] " + trustMsg
|
||||
}
|
||||
right := "Ctrl-K · palette"
|
||||
|
||||
pad := int(cols) - len(left) - len(right)
|
||||
@@ -490,6 +549,48 @@ func (st *uiState) stdinLoop() error {
|
||||
func (st *uiState) processStdin(chunk []byte) {
|
||||
st.mu.Lock()
|
||||
|
||||
// Trust modal is modal: y/Y accepts, n/N or ESC denies. Everything
|
||||
// else is ignored so a typo doesn't leak into the focused PTY while
|
||||
// the prompt is up. SPEC §7 trust gate.
|
||||
if st.pendingTrust != nil {
|
||||
req := *st.pendingTrust
|
||||
consumed := 0
|
||||
var resolved string
|
||||
for _, b := range chunk {
|
||||
consumed++
|
||||
switch b {
|
||||
case 'y', 'Y':
|
||||
resolved = "accept"
|
||||
case 'n', 'N', 0x1b: // ESC
|
||||
resolved = "deny"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if resolved != "" {
|
||||
st.pendingTrust = nil
|
||||
st.mu.Unlock()
|
||||
if resolved == "accept" {
|
||||
if err := st.trust.Grant(req.presetName); err != nil {
|
||||
st.flashError(fmt.Sprintf("trust grant: %v", err))
|
||||
} else {
|
||||
st.flashTransient(fmt.Sprintf("trusted preset %q (retry the call)", req.presetName))
|
||||
}
|
||||
} else {
|
||||
st.flashTransient(fmt.Sprintf("denied trust for preset %q", req.presetName))
|
||||
}
|
||||
st.drawStatusLine()
|
||||
// Discard the rest of the chunk; we intentionally don't
|
||||
// recurse into the regular handler so a stray Enter doesn't
|
||||
// submit anything to the focused PTY.
|
||||
_ = consumed
|
||||
return
|
||||
}
|
||||
st.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
forward := make([]byte, 0, len(chunk))
|
||||
flushForward := func() {
|
||||
if len(forward) == 0 {
|
||||
@@ -641,7 +742,7 @@ func (st *uiState) closePalette(action paletteAction) {
|
||||
}
|
||||
l := st.layoutSnapshot()
|
||||
st.launcher.SetSize(l.childCols(), l.childRows())
|
||||
if _, err := st.launcher.LaunchProcess(action.preset, action.preset.Name); err != nil {
|
||||
if _, err := st.launcher.LaunchCommandPreset(action.preset, action.preset.Name, ""); err != nil {
|
||||
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
|
||||
}
|
||||
|
||||
@@ -687,6 +788,16 @@ func (st *uiState) flashError(msg string) {
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// flashTransient is the softer cousin of flashError used for
|
||||
// trust-prompt resolutions. Same status-line surface; the prefix differs.
|
||||
func (st *uiState) flashTransient(msg string) {
|
||||
st.mu.Lock()
|
||||
st.attentionText = msg
|
||||
st.attentionAt = ""
|
||||
st.mu.Unlock()
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// repaintFocused redraws the current focused child's screen snapshot.
|
||||
// Callers must NOT hold st.mu — repaintFocused takes it
|
||||
// briefly itself.
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
@@ -15,20 +18,34 @@ import (
|
||||
"github.com/harrybrwn/patterm/internal/vt"
|
||||
)
|
||||
|
||||
// portRegex matches dev-server URLs of the form `http(s)://host:NNNN[/path]`
|
||||
// and reports the port. SPEC §7 get_process_ports is best-effort; we
|
||||
// stick to URL-form sightings because bare `:NNNN` produces too many
|
||||
// false positives (timestamps, exit codes, etc.).
|
||||
var portRegex = regexp.MustCompile(`https?://[^\s:/]+:(\d{2,5})(?:/[^\s]*)?`)
|
||||
|
||||
type ChildStatus string
|
||||
|
||||
const (
|
||||
StatusRunning ChildStatus = "running"
|
||||
StatusExited ChildStatus = "exited"
|
||||
StatusErrored ChildStatus = "errored"
|
||||
StatusStarting ChildStatus = "starting"
|
||||
StatusRunning ChildStatus = "running"
|
||||
StatusStopped ChildStatus = "stopped"
|
||||
StatusExited ChildStatus = "exited"
|
||||
StatusErrored ChildStatus = "errored"
|
||||
)
|
||||
|
||||
// ChildKind matches the two preset flavours in SPEC §10.
|
||||
// ChildKind matches the three process kinds in SPEC §7.
|
||||
// - agent: vendor LLM CLI launched from an agent preset (MCP-wired,
|
||||
// ephemeral — lost when the PTY exits).
|
||||
// - terminal: a bare interactive shell (ephemeral).
|
||||
// - command: a process preset or freeform argv (session-persistent —
|
||||
// survives PTY exit so it can be restart_process'd).
|
||||
type ChildKind string
|
||||
|
||||
const (
|
||||
KindAgent ChildKind = "agent"
|
||||
KindProcess ChildKind = "process"
|
||||
KindAgent ChildKind = "agent"
|
||||
KindTerminal ChildKind = "terminal"
|
||||
KindCommand ChildKind = "command"
|
||||
)
|
||||
|
||||
// Owner reflects the SPEC §6 input-ownership flag.
|
||||
@@ -39,86 +56,192 @@ const (
|
||||
OwnerOrchestrator Owner = "orchestrator"
|
||||
)
|
||||
|
||||
// Child is one PTY-backed process plus its emulator. The same struct
|
||||
// represents both agent presets (with MCP) and process presets (raw).
|
||||
// Child is one entry in the session — a PTY-backed process plus its
|
||||
// emulator. Covers all three kinds (agent / terminal / command).
|
||||
//
|
||||
// For KindCommand the entry is session-persistent: argv/env/workingDir
|
||||
// stay populated across stop/restart so Restart() can rebuild the PTY
|
||||
// against the same spec.
|
||||
type Child struct {
|
||||
ID string
|
||||
Name string
|
||||
Argv []string
|
||||
Env []string
|
||||
WorkDir string
|
||||
Kind ChildKind
|
||||
ParentID string // empty for top-level sessions
|
||||
|
||||
// PresetRef names the source preset (when known). Used by trust
|
||||
// gating to re-check on restart_process. Empty for freeform-argv
|
||||
// command entries and for ephemeral terminals.
|
||||
PresetRef string
|
||||
|
||||
// Identity is the per-spawn token the mcp-stdio proxy uses to
|
||||
// identify itself when calling tools. Empty for process presets.
|
||||
// identify itself when calling tools. Empty for non-agent entries.
|
||||
Identity string
|
||||
|
||||
pty *pkgpty.PTY
|
||||
em *vt.GhosttyEmulator
|
||||
// nameMu guards Name (rename_process).
|
||||
nameMu sync.RWMutex
|
||||
|
||||
// ptyMu guards pty + em so Restart can swap them while pumpChild /
|
||||
// reapChild loops detect the swap by observing nil/closed PTY.
|
||||
ptyMu sync.RWMutex
|
||||
pty *pkgpty.PTY
|
||||
em *vt.GhosttyEmulator
|
||||
|
||||
status atomic.Pointer[ChildStatus]
|
||||
exitCode atomic.Int32
|
||||
|
||||
owner atomic.Pointer[Owner]
|
||||
|
||||
// lastWrite is the wall time of the most recent PTY-master write.
|
||||
// lastWriteNS is the wall time of the most recent PTY-master write.
|
||||
// SPEC §11 idle heuristic: a pane is idle once nothing has been
|
||||
// written for the preset's threshold (default 1s).
|
||||
lastWriteNS atomic.Int64
|
||||
|
||||
// screenVersion increments on every PTY-out chunk. SPEC §7
|
||||
// get_process_output exposes it so orchestrators can detect changes
|
||||
// without diffing content.
|
||||
screenVersion atomic.Int64
|
||||
|
||||
// ringMu guards ring. The ring buffer carries the last `ringCap`
|
||||
// bytes the PTY produced, used by SPEC §7 read_output stream mode.
|
||||
// bytes the PTY produced, used by SPEC §7 get_process_output stream
|
||||
// mode and search_output scrollback.
|
||||
ringMu sync.Mutex
|
||||
ring []byte
|
||||
ringStart int64 // absolute offset of ring[0]
|
||||
ringWrites int64 // cumulative bytes written
|
||||
|
||||
// portsMu guards ports. Best-effort port detection: regex on stream.
|
||||
portsMu sync.Mutex
|
||||
ports []PortSighting
|
||||
}
|
||||
|
||||
// PortSighting is one entry returned by get_process_ports.
|
||||
type PortSighting struct {
|
||||
Port int `json:"port"`
|
||||
URL string `json:"url,omitempty"`
|
||||
FirstSeenAt string `json:"first_seen_at"`
|
||||
}
|
||||
|
||||
const ringCap = 1 << 20 // 1 MiB per SPEC §5
|
||||
|
||||
func newChild(id, name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) {
|
||||
if len(argv) == 0 {
|
||||
return nil, errors.New("child: empty argv")
|
||||
}
|
||||
em, err := vt.NewGhosttyEmulator(cols, rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("child %s emulator: %w", id, err)
|
||||
}
|
||||
p, err := pkgpty.Start(argv, env, cols, rows)
|
||||
if err != nil {
|
||||
em.Close()
|
||||
return nil, fmt.Errorf("child %s pty: %w", id, err)
|
||||
}
|
||||
// newChildEntry builds the in-memory Child record but does NOT start a
|
||||
// PTY. Used so command entries can exist in the `stopped` state from the
|
||||
// moment they're created. Agents and terminals call newChild() which
|
||||
// chains newChildEntry + startPTY for the initial run.
|
||||
func newChildEntry(id, name string, kind ChildKind, argv, env []string, parentID, workDir, presetRef string) *Child {
|
||||
c := &Child{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Argv: argv,
|
||||
Kind: kind,
|
||||
ParentID: parentID,
|
||||
pty: p,
|
||||
em: em,
|
||||
ring: make([]byte, 0, ringCap),
|
||||
ID: id,
|
||||
Name: name,
|
||||
Argv: argv,
|
||||
Env: env,
|
||||
WorkDir: workDir,
|
||||
Kind: kind,
|
||||
ParentID: parentID,
|
||||
PresetRef: presetRef,
|
||||
ring: make([]byte, 0, ringCap),
|
||||
}
|
||||
st := StatusRunning
|
||||
st := StatusStopped
|
||||
c.status.Store(&st)
|
||||
c.exitCode.Store(-1)
|
||||
// Agents spawned by an orchestrator default to orchestrator-owned;
|
||||
// everything else (top-level, processes) defaults to user. SPEC §6.
|
||||
def := OwnerUser
|
||||
if kind == KindAgent && parentID != "" {
|
||||
def = OwnerOrchestrator
|
||||
}
|
||||
c.owner.Store(&def)
|
||||
|
||||
if kind == KindAgent {
|
||||
c.Identity = mintIdentity()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func newChild(id, name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID, workDir, presetRef string) (*Child, error) {
|
||||
if len(argv) == 0 {
|
||||
return nil, errors.New("child: empty argv")
|
||||
}
|
||||
c := newChildEntry(id, name, kind, argv, env, parentID, workDir, presetRef)
|
||||
if err := c.startPTY(cols, rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// startPTY (re)builds the emulator + PTY for this entry. Called by
|
||||
// newChild on initial spawn and by Restart on subsequent runs. The
|
||||
// status transitions stopped/exited → starting → running. On error the
|
||||
// entry returns to errored.
|
||||
func (c *Child) startPTY(cols, rows uint16) error {
|
||||
em, err := vt.NewGhosttyEmulator(cols, rows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("child %s emulator: %w", c.ID, err)
|
||||
}
|
||||
starting := StatusStarting
|
||||
c.status.Store(&starting)
|
||||
p, err := pkgpty.Start(c.Argv, c.Env, cols, rows)
|
||||
if err != nil {
|
||||
em.Close()
|
||||
errored := StatusErrored
|
||||
c.status.Store(&errored)
|
||||
return fmt.Errorf("child %s pty: %w", c.ID, err)
|
||||
}
|
||||
em.OnWritePTY(func(b []byte) {
|
||||
_, _ = p.Write(b)
|
||||
})
|
||||
return c, nil
|
||||
c.ptyMu.Lock()
|
||||
c.pty = p
|
||||
c.em = em
|
||||
c.ptyMu.Unlock()
|
||||
running := StatusRunning
|
||||
c.status.Store(&running)
|
||||
c.exitCode.Store(-1)
|
||||
c.lastWriteNS.Store(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsLive reports whether the PTY is currently attached and running.
|
||||
// Used by callers that need to gate input on a live PTY (vs. a stopped
|
||||
// command entry).
|
||||
func (c *Child) IsLive() bool {
|
||||
st := c.Status()
|
||||
return st == StatusStarting || st == StatusRunning
|
||||
}
|
||||
|
||||
// PTY returns the current PTY pointer under read-lock. May be nil for a
|
||||
// stopped command entry.
|
||||
func (c *Child) PTY() *pkgpty.PTY {
|
||||
c.ptyMu.RLock()
|
||||
defer c.ptyMu.RUnlock()
|
||||
return c.pty
|
||||
}
|
||||
|
||||
// Emulator returns the current emulator pointer under read-lock.
|
||||
func (c *Child) Emulator() *vt.GhosttyEmulator {
|
||||
c.ptyMu.RLock()
|
||||
defer c.ptyMu.RUnlock()
|
||||
return c.em
|
||||
}
|
||||
|
||||
// DisplayName is the rename_process-aware accessor for Name. Callers
|
||||
// that read Name directly skip the lock; the field is still safe to
|
||||
// read because Go strings are immutable, but DisplayName signals intent.
|
||||
func (c *Child) DisplayName() string {
|
||||
c.nameMu.RLock()
|
||||
defer c.nameMu.RUnlock()
|
||||
return c.Name
|
||||
}
|
||||
|
||||
// SetName updates the display name (rename_process).
|
||||
func (c *Child) SetName(name string) {
|
||||
c.nameMu.Lock()
|
||||
c.Name = name
|
||||
c.nameMu.Unlock()
|
||||
}
|
||||
|
||||
// ScreenVersion returns the current emulator snapshot version, bumped
|
||||
// on every PTY-out chunk.
|
||||
func (c *Child) ScreenVersion() int64 { return c.screenVersion.Load() }
|
||||
|
||||
func (c *Child) Status() ChildStatus {
|
||||
st := c.status.Load()
|
||||
if st == nil {
|
||||
@@ -129,7 +252,13 @@ func (c *Child) Status() ChildStatus {
|
||||
|
||||
func (c *Child) ExitCode() int { return int(c.exitCode.Load()) }
|
||||
|
||||
func (c *Child) PID() int { return c.pty.Pid() }
|
||||
func (c *Child) PID() int {
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return 0
|
||||
}
|
||||
return pty.Pid()
|
||||
}
|
||||
|
||||
func (c *Child) Owner() Owner {
|
||||
o := c.owner.Load()
|
||||
@@ -153,8 +282,8 @@ func (c *Child) IdleMS() int64 {
|
||||
|
||||
func (c *Child) recordWrite(chunk []byte) {
|
||||
c.lastWriteNS.Store(time.Now().UnixNano())
|
||||
c.screenVersion.Add(1)
|
||||
c.ringMu.Lock()
|
||||
defer c.ringMu.Unlock()
|
||||
c.ring = append(c.ring, chunk...)
|
||||
c.ringWrites += int64(len(chunk))
|
||||
if len(c.ring) > ringCap {
|
||||
@@ -162,6 +291,52 @@ func (c *Child) recordWrite(chunk []byte) {
|
||||
c.ring = c.ring[drop:]
|
||||
c.ringStart += int64(drop)
|
||||
}
|
||||
c.ringMu.Unlock()
|
||||
c.scanPortsFromChunk(chunk)
|
||||
}
|
||||
|
||||
// scanPortsFromChunk does best-effort port detection on a PTY chunk.
|
||||
// SPEC §7 get_process_ports — no probing, just stream scanning.
|
||||
func (c *Child) scanPortsFromChunk(chunk []byte) {
|
||||
matches := portRegex.FindAllSubmatch(chunk, -1)
|
||||
if len(matches) == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
c.portsMu.Lock()
|
||||
defer c.portsMu.Unlock()
|
||||
for _, m := range matches {
|
||||
urlForm := string(m[0])
|
||||
portStr := string(m[1])
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
continue
|
||||
}
|
||||
seen := false
|
||||
for _, p := range c.ports {
|
||||
if p.Port == port {
|
||||
seen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if seen {
|
||||
continue
|
||||
}
|
||||
ent := PortSighting{Port: port, FirstSeenAt: now}
|
||||
if strings.HasPrefix(urlForm, "http") {
|
||||
ent.URL = urlForm
|
||||
}
|
||||
c.ports = append(c.ports, ent)
|
||||
}
|
||||
}
|
||||
|
||||
// Ports returns a snapshot of detected port sightings.
|
||||
func (c *Child) Ports() []PortSighting {
|
||||
c.portsMu.Lock()
|
||||
defer c.portsMu.Unlock()
|
||||
out := make([]PortSighting, len(c.ports))
|
||||
copy(out, c.ports)
|
||||
return out
|
||||
}
|
||||
|
||||
// StreamRead returns ring bytes from `since` to the current write head,
|
||||
@@ -185,7 +360,11 @@ func (c *Child) StreamRead(since int64) ([]byte, int64) {
|
||||
}
|
||||
|
||||
func (c *Child) signal(sig syscall.Signal) error {
|
||||
pid := c.pty.Pid()
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return errors.New("child has no pty")
|
||||
}
|
||||
pid := pty.Pid()
|
||||
if pid <= 0 {
|
||||
return errors.New("child has no pid")
|
||||
}
|
||||
@@ -211,20 +390,43 @@ func (c *Child) markExited(err error) {
|
||||
c.status.Store(&st)
|
||||
}
|
||||
|
||||
// teardownPTY closes the current PTY/emulator and nils them out. Used
|
||||
// by Restart so the new PTY can take their place. Safe to call when
|
||||
// they're already nil.
|
||||
func (c *Child) teardownPTY() {
|
||||
c.ptyMu.Lock()
|
||||
p, em := c.pty, c.em
|
||||
c.pty, c.em = nil, nil
|
||||
c.ptyMu.Unlock()
|
||||
if p != nil {
|
||||
_ = p.Close()
|
||||
}
|
||||
if em != nil {
|
||||
_ = em.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// InjectAsUser is the path the human takes when typing in the focused
|
||||
// pane. SPEC §6: the user's first keystroke flips ownership.
|
||||
func (c *Child) InjectAsUser(b []byte) error {
|
||||
c.SetOwner(OwnerUser)
|
||||
_, err := c.pty.Write(b)
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return errors.New("child has no pty")
|
||||
}
|
||||
_, err := pty.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
// InjectAsOrchestrator is the path send_message_to / report_to_parent /
|
||||
// initial_prompt / timer_wait writes take. Ownership flips back to
|
||||
// orchestrator. SPEC §6.
|
||||
// InjectAsOrchestrator is the path send_message / initial_prompt /
|
||||
// timer_wait writes take. Ownership flips back to orchestrator. SPEC §6.
|
||||
func (c *Child) InjectAsOrchestrator(b []byte) error {
|
||||
c.SetOwner(OwnerOrchestrator)
|
||||
_, err := c.pty.Write(b)
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return errors.New("child has no pty")
|
||||
}
|
||||
_, err := pty.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -233,3 +435,12 @@ func mintIdentity() string {
|
||||
_, _ = rand.Read(buf[:])
|
||||
return hex.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
// mintProcessID generates the opaque short token SPEC §7 calls a
|
||||
// process_id: lowercase `p_` followed by 6 hex chars. Collisions inside
|
||||
// one session are checked by the caller (session.go).
|
||||
func mintProcessID() string {
|
||||
var buf [3]byte
|
||||
_, _ = rand.Read(buf[:])
|
||||
return "p_" + hex.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
1004
internal/app/host.go
1004
internal/app/host.go
File diff suppressed because it is too large
Load Diff
99
internal/app/host_test.go
Normal file
99
internal/app/host_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mkChild builds a Child without starting a PTY. Use sparingly — the
|
||||
// resulting child has no emulator / ring buffer / status pointer set
|
||||
// the way newChild would.
|
||||
func mkChild(id, name, parent string) *Child {
|
||||
return &Child{ID: id, Name: name, ParentID: parent}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageOrchestratorToChild(t *testing.T) {
|
||||
parent := mkChild("p_aaa", "claude-1", "")
|
||||
child := mkChild("p_bbb", "codex-1", "p_aaa")
|
||||
|
||||
line, err := classifySendMessage(parent, child, parent.ID, "hi child")
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "[orchestrator] hi child") {
|
||||
t.Fatalf("parent→child should tag [orchestrator], got %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageChildToParent(t *testing.T) {
|
||||
parent := mkChild("p_aaa", "claude-1", "")
|
||||
child := mkChild("p_bbb", "codex-1", "p_aaa")
|
||||
|
||||
line, err := classifySendMessage(child, parent, child.ID, "status update")
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
if !strings.Contains(line, "[sub-agent:codex-1]") {
|
||||
t.Fatalf("child→parent should tag [sub-agent:<name>], got %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageSiblingRejected(t *testing.T) {
|
||||
parent := mkChild("p_aaa", "claude-1", "")
|
||||
sibA := mkChild("p_bbb", "codex-1", "p_aaa")
|
||||
sibB := mkChild("p_ccc", "codex-2", "p_aaa")
|
||||
_ = parent
|
||||
|
||||
_, err := classifySendMessage(sibA, sibB, sibA.ID, "hey")
|
||||
if err == nil {
|
||||
t.Fatalf("sibling send_message should fail with not_related")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "neither parent nor child") {
|
||||
t.Fatalf("error should mention sibling routing rule, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageUnrelatedRejected(t *testing.T) {
|
||||
// Two unrelated top-level processes — they have no shared lineage.
|
||||
a := mkChild("p_aaa", "claude-1", "")
|
||||
b := mkChild("p_bbb", "codex-1", "")
|
||||
|
||||
_, err := classifySendMessage(a, b, a.ID, "hi")
|
||||
if err == nil {
|
||||
t.Fatalf("unrelated top-level send_message should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageCannotSendToSelf(t *testing.T) {
|
||||
c := mkChild("p_aaa", "claude-1", "")
|
||||
_, err := classifySendMessage(c, c, c.ID, "talking to myself")
|
||||
if err == nil {
|
||||
t.Fatalf("self-send should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageNilCallerAcceptsTopLevel(t *testing.T) {
|
||||
// Caller arrived over an MCP connection without a resolved patterm
|
||||
// identity (top-level tool client). Target is top-level; should
|
||||
// land as [orchestrator].
|
||||
target := mkChild("p_aaa", "claude-1", "")
|
||||
line, err := classifySendMessage(nil, target, "" /* unknown caller id */, "go")
|
||||
if err != nil {
|
||||
t.Fatalf("expected success for nil-caller → top-level target, got %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "[orchestrator] go") {
|
||||
t.Fatalf("expected [orchestrator] tag, got %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageNilCallerRejectsNonTopLevelTarget(t *testing.T) {
|
||||
parent := mkChild("p_aaa", "claude-1", "")
|
||||
child := mkChild("p_bbb", "codex-1", "p_aaa")
|
||||
_ = parent
|
||||
|
||||
_, err := classifySendMessage(nil, child, "", "hi")
|
||||
if err == nil {
|
||||
t.Fatalf("nil caller → non-top-level should fail")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,15 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
|
||||
|
||||
// Spawn with the chosen identity.
|
||||
cols, rows := l.size()
|
||||
c, err := l.sess.spawnWithIdentity(displayName, KindAgent, argv, env, cols, rows, parentID, identity)
|
||||
c, err := l.sess.Spawn(SpawnSpec{
|
||||
Kind: KindAgent,
|
||||
Argv: argv,
|
||||
Env: env,
|
||||
Name: displayName,
|
||||
ParentID: parentID,
|
||||
PresetRef: p.Name,
|
||||
Identity: identity,
|
||||
}, cols, rows)
|
||||
if err != nil {
|
||||
_ = os.Remove(mcpConfigPath)
|
||||
return nil, err
|
||||
@@ -111,17 +119,72 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// LaunchProcess spawns a process preset. No MCP injection; just argv.
|
||||
func (l *Launcher) LaunchProcess(p *preset.Preset, displayName string) (*Child, error) {
|
||||
if p.Kind != preset.KindProcess {
|
||||
return nil, fmt.Errorf("launch: %q is not a process preset", p.Name)
|
||||
// LaunchCommandPreset spawns a process preset as a SPEC §7 command
|
||||
// entry. No MCP injection; just argv. The entry is session-persistent
|
||||
// (survives PTY exit so it can be Restart'd).
|
||||
func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID string) (*Child, error) {
|
||||
if p.Kind != preset.KindCommand {
|
||||
return nil, fmt.Errorf("launch: %q is not a command preset", p.Name)
|
||||
}
|
||||
env := l.sess.ChildEnv()
|
||||
for k, v := range p.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
cols, rows := l.size()
|
||||
return l.sess.Spawn(displayName, KindProcess, p.ResolvedArgv(), env, cols, rows, "")
|
||||
return l.sess.Spawn(SpawnSpec{
|
||||
Kind: KindCommand,
|
||||
Argv: p.ResolvedArgv(),
|
||||
Env: env,
|
||||
Name: displayName,
|
||||
ParentID: parentID,
|
||||
WorkDir: p.WorkingDir,
|
||||
PresetRef: p.Name,
|
||||
}, cols, rows)
|
||||
}
|
||||
|
||||
// LaunchCommandArgv spawns a freeform-argv command entry. Trust gating
|
||||
// (SPEC §7) lives one level up in toolHost — by the time we get here
|
||||
// trust is settled (freeform argv is implicitly trusted).
|
||||
func (l *Launcher) LaunchCommandArgv(argv []string, displayName, parentID, workDir string, env []string, shell bool) (*Child, error) {
|
||||
if shell && len(argv) > 0 {
|
||||
argv = []string{"sh", "-lc", strings.Join(argv, " ")}
|
||||
}
|
||||
if env == nil {
|
||||
env = l.sess.ChildEnv()
|
||||
}
|
||||
cols, rows := l.size()
|
||||
return l.sess.Spawn(SpawnSpec{
|
||||
Kind: KindCommand,
|
||||
Argv: argv,
|
||||
Env: env,
|
||||
Name: displayName,
|
||||
ParentID: parentID,
|
||||
WorkDir: workDir,
|
||||
}, cols, rows)
|
||||
}
|
||||
|
||||
// LaunchTerminal spawns a bare interactive shell. SPEC §7 kind=terminal.
|
||||
// argv defaults to $SHELL -i when empty.
|
||||
func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir string, env []string) (*Child, error) {
|
||||
if len(argv) == 0 {
|
||||
sh := os.Getenv("SHELL")
|
||||
if sh == "" {
|
||||
sh = "/bin/sh"
|
||||
}
|
||||
argv = []string{sh, "-i"}
|
||||
}
|
||||
if env == nil {
|
||||
env = l.sess.ChildEnv()
|
||||
}
|
||||
cols, rows := l.size()
|
||||
return l.sess.Spawn(SpawnSpec{
|
||||
Kind: KindTerminal,
|
||||
Argv: argv,
|
||||
Env: env,
|
||||
Name: displayName,
|
||||
ParentID: parentID,
|
||||
WorkDir: workDir,
|
||||
}, cols, rows)
|
||||
}
|
||||
|
||||
func (l *Launcher) writeMCPConfig() (identity, path string, err error) {
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/harrybrwn/patterm/internal/vt"
|
||||
)
|
||||
@@ -29,7 +29,10 @@ type Session struct {
|
||||
children map[string]*Child
|
||||
order []string
|
||||
|
||||
nextChildSeq atomic.Int64
|
||||
// nameSeq tracks the default-name counter per kind (agent-1,
|
||||
// command-2, terminal-3, …). Reset is a non-goal: counters are
|
||||
// monotonic across the session lifetime.
|
||||
nameSeq map[ChildKind]int
|
||||
|
||||
// listeners is the set of UI listeners that want to hear about child
|
||||
// lifecycle events (spawn/exit) — exactly one (the TUI) in v1.
|
||||
@@ -53,6 +56,7 @@ func NewSession(projectDir, projectKey string) *Session {
|
||||
projectDir: projectDir,
|
||||
projectKey: projectKey,
|
||||
children: make(map[string]*Child),
|
||||
nameSeq: make(map[ChildKind]int),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,24 +106,45 @@ func (s *Session) ChildEnv() []string {
|
||||
return env
|
||||
}
|
||||
|
||||
// Spawn launches a new child with the given argv. kind is "agent" or
|
||||
// "process". parentID names the calling session/child for orchestrator
|
||||
// trees ("" for top-level). env is the full child environment; the
|
||||
// caller is responsible for adding preset-specific overrides.
|
||||
func (s *Session) Spawn(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) {
|
||||
// SpawnSpec is the argument record for Session.Spawn — the new
|
||||
// argv-shaped spawn API matching SPEC §7 spawn_process.
|
||||
type SpawnSpec struct {
|
||||
Kind ChildKind
|
||||
Argv []string
|
||||
Env []string
|
||||
WorkDir string
|
||||
Name string
|
||||
ParentID string
|
||||
PresetRef string
|
||||
Identity string // pre-minted; otherwise the constructor mints one for agents
|
||||
}
|
||||
|
||||
// Spawn creates a new entry and starts its PTY. For Kind = command the
|
||||
// entry remains in the session after PTY exit (it can be Restart'd).
|
||||
// For agent/terminal the entry's lifetime equals the PTY's: reapChild
|
||||
// fires emitExit and the entry stays in `exited` status until the
|
||||
// caller `close_process`'s it.
|
||||
func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
|
||||
if len(spec.Argv) == 0 {
|
||||
return nil, errors.New("session.Spawn: empty argv")
|
||||
}
|
||||
if spec.Env == nil {
|
||||
spec.Env = s.ChildEnv()
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
id := fmt.Sprintf("c%d", s.nextChildSeq.Add(1))
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s-%s", kind, id)
|
||||
id := s.mintUniqueIDLocked()
|
||||
s.nameSeq[spec.Kind]++
|
||||
if spec.Name == "" {
|
||||
spec.Name = fmt.Sprintf("%s-%d", spec.Kind, s.nameSeq[spec.Kind])
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if env == nil {
|
||||
env = s.ChildEnv()
|
||||
c := newChildEntry(id, spec.Name, spec.Kind, spec.Argv, spec.Env, spec.ParentID, spec.WorkDir, spec.PresetRef)
|
||||
if spec.Identity != "" {
|
||||
c.Identity = spec.Identity
|
||||
}
|
||||
|
||||
c, err := newChild(id, name, kind, argv, env, cols, rows, parentID)
|
||||
if err != nil {
|
||||
if err := c.startPTY(cols, rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -134,27 +159,148 @@ func (s *Session) Spawn(name string, kind ChildKind, argv, env []string, cols, r
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// spawnWithIdentity is like Spawn but lets the launcher pre-mint the
|
||||
// MCP identity so the config file can be written before the process
|
||||
// starts.
|
||||
func (s *Session) spawnWithIdentity(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID, identity string) (*Child, error) {
|
||||
c, err := s.Spawn(name, kind, argv, env, cols, rows, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// AddCommandEntry registers a command entry without starting it. Used
|
||||
// by spawn_process(kind: command) when SPEC §7 needs the entry to exist
|
||||
// in `stopped` state first (we always start it after; the indirection
|
||||
// is here so future versions can support deferred starts).
|
||||
func (s *Session) AddCommandEntry(spec SpawnSpec) *Child {
|
||||
s.mu.Lock()
|
||||
id := s.mintUniqueIDLocked()
|
||||
s.nameSeq[spec.Kind]++
|
||||
if spec.Name == "" {
|
||||
spec.Name = fmt.Sprintf("%s-%d", spec.Kind, s.nameSeq[spec.Kind])
|
||||
}
|
||||
if spec.Env == nil {
|
||||
spec.Env = s.ChildEnv()
|
||||
}
|
||||
c := newChildEntry(id, spec.Name, spec.Kind, spec.Argv, spec.Env, spec.ParentID, spec.WorkDir, spec.PresetRef)
|
||||
s.children[id] = c
|
||||
s.order = append(s.order, id)
|
||||
s.mu.Unlock()
|
||||
s.emitSpawn(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// Start (re)attaches a PTY to an entry that is currently stopped or
|
||||
// exited. Errors if the entry is already live.
|
||||
func (s *Session) Start(id string, cols, rows uint16) error {
|
||||
c := s.FindChild(id)
|
||||
if c == nil {
|
||||
return fmt.Errorf("no such process %q", id)
|
||||
}
|
||||
if c.IsLive() {
|
||||
return nil // SPEC §7 start_process is a no-op on a running entry
|
||||
}
|
||||
if err := c.startPTY(cols, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
go s.pumpChild(c)
|
||||
go s.reapChild(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart stops the entry (if live) then starts it again with the same
|
||||
// argv/env/workdir. Per SPEC §7: valid for command entries; valid for
|
||||
// agent/terminal only while their PTY is still live.
|
||||
func (s *Session) Restart(id string, sig syscall.Signal, cols, rows uint16) error {
|
||||
c := s.FindChild(id)
|
||||
if c == nil {
|
||||
return fmt.Errorf("no such process %q", id)
|
||||
}
|
||||
if c.Kind != KindCommand && !c.IsLive() {
|
||||
return fmt.Errorf("restart: %s entries can only be restarted while live", c.Kind)
|
||||
}
|
||||
if c.IsLive() {
|
||||
if sig == 0 {
|
||||
sig = syscall.SIGTERM
|
||||
}
|
||||
_ = c.signal(sig)
|
||||
// Wait briefly for the reaper to mark exited. We don't need
|
||||
// strict synchronization — the reaper will run regardless; we
|
||||
// just want startPTY to land after teardown.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for c.IsLive() && time.Now().Before(deadline) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if c.IsLive() {
|
||||
// Force.
|
||||
_ = c.signal(syscall.SIGKILL)
|
||||
for c.IsLive() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.teardownPTY()
|
||||
if err := c.startPTY(cols, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
go s.pumpChild(c)
|
||||
go s.reapChild(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close removes an entry from the session entirely. If still live,
|
||||
// stops it first. SPEC §7 close_process.
|
||||
func (s *Session) Close(id string, sig syscall.Signal) error {
|
||||
c := s.FindChild(id)
|
||||
if c == nil {
|
||||
return fmt.Errorf("no such process %q", id)
|
||||
}
|
||||
if c.IsLive() {
|
||||
if sig == 0 {
|
||||
sig = syscall.SIGTERM
|
||||
}
|
||||
_ = c.signal(sig)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for c.IsLive() && time.Now().Before(deadline) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if c.IsLive() {
|
||||
_ = c.signal(syscall.SIGKILL)
|
||||
for c.IsLive() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.teardownPTY()
|
||||
s.mu.Lock()
|
||||
delete(s.children, id)
|
||||
for i, oid := range s.order {
|
||||
if oid == id {
|
||||
s.order = append(s.order[:i], s.order[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// mintUniqueIDLocked mints an opaque process_id (SPEC §7) and retries
|
||||
// if it collides with an existing entry. Caller holds s.mu.
|
||||
func (s *Session) mintUniqueIDLocked() string {
|
||||
for {
|
||||
id := mintProcessID()
|
||||
if _, exists := s.children[id]; !exists {
|
||||
return id
|
||||
}
|
||||
}
|
||||
c.Identity = identity
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Session) pumpChild(c *Child) {
|
||||
buf := make([]byte, 64*1024)
|
||||
for {
|
||||
n, err := c.pty.Read(buf)
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return
|
||||
}
|
||||
n, err := pty.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
if _, werr := c.em.Write(chunk); werr != nil {
|
||||
logf("emulator.Write(child %s): %v", c.ID, werr)
|
||||
if em := c.Emulator(); em != nil {
|
||||
if _, werr := em.Write(chunk); werr != nil {
|
||||
logf("emulator.Write(child %s): %v", c.ID, werr)
|
||||
}
|
||||
}
|
||||
c.recordWrite(chunk)
|
||||
s.emitPTYOut(c.ID, chunk)
|
||||
@@ -169,7 +315,11 @@ func (s *Session) pumpChild(c *Child) {
|
||||
}
|
||||
|
||||
func (s *Session) reapChild(c *Child) {
|
||||
err := c.pty.Wait()
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return
|
||||
}
|
||||
err := pty.Wait()
|
||||
c.markExited(err)
|
||||
logf("child %s exited (err=%v)", c.ID, err)
|
||||
s.emitExit(c)
|
||||
@@ -233,7 +383,11 @@ func (s *Session) WriteInput(id string, b []byte) error {
|
||||
if c.Status() != StatusRunning {
|
||||
return fmt.Errorf("child %q is %s", id, c.Status())
|
||||
}
|
||||
_, err := c.pty.Write(b)
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return fmt.Errorf("child %q has no pty", id)
|
||||
}
|
||||
_, err := pty.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -250,8 +404,12 @@ func (s *Session) ResizeAll(cols, rows uint16) {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, c := range cs {
|
||||
_ = c.pty.Resize(cols, rows)
|
||||
_ = c.em.Resize(cols, rows)
|
||||
if pty := c.PTY(); pty != nil {
|
||||
_ = pty.Resize(cols, rows)
|
||||
}
|
||||
if em := c.Emulator(); em != nil {
|
||||
_ = em.Resize(cols, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +421,11 @@ func (s *Session) SerializeChild(id string) ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("no such child %q", id)
|
||||
}
|
||||
return c.em.SerializeVT()
|
||||
em := c.Emulator()
|
||||
if em == nil {
|
||||
return nil, fmt.Errorf("child %q has no emulator", id)
|
||||
}
|
||||
return em.SerializeVT()
|
||||
}
|
||||
|
||||
func (s *Session) SnapshotChild(id string) (string, vt.CursorState, error) {
|
||||
@@ -271,11 +433,15 @@ func (s *Session) SnapshotChild(id string) (string, vt.CursorState, error) {
|
||||
if c == nil {
|
||||
return "", vt.CursorState{}, fmt.Errorf("no such child %q", id)
|
||||
}
|
||||
text, err := c.em.ScreenText()
|
||||
em := c.Emulator()
|
||||
if em == nil {
|
||||
return "", vt.CursorState{}, fmt.Errorf("child %q has no emulator", id)
|
||||
}
|
||||
text, err := em.ScreenText()
|
||||
if err != nil {
|
||||
return "", vt.CursorState{}, err
|
||||
}
|
||||
cursor, err := c.em.Cursor()
|
||||
cursor, err := em.Cursor()
|
||||
if err != nil {
|
||||
return "", vt.CursorState{}, err
|
||||
}
|
||||
@@ -297,8 +463,7 @@ func (s *Session) Shutdown() {
|
||||
// Close emulators and PTY masters. The reaper goroutines will fire
|
||||
// emitExit as Wait() returns.
|
||||
for _, c := range cs {
|
||||
_ = c.pty.Close()
|
||||
_ = c.em.Close()
|
||||
c.teardownPTY()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user