diff --git a/internal/app/app.go b/internal/app/app.go index 0de52fa..7bc278d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 " / "Deny preset ". 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. diff --git a/internal/app/child.go b/internal/app/child.go index 723c8a5..f9bb748 100644 --- a/internal/app/child.go +++ b/internal/app/child.go @@ -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[:]) +} diff --git a/internal/app/host.go b/internal/app/host.go index ccfba7a..40020c7 100644 --- a/internal/app/host.go +++ b/internal/app/host.go @@ -9,36 +9,70 @@ import ( "time" "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" + pkgvt "github.com/harrybrwn/patterm/internal/vt" ) // attentionSink is implemented by uiState to surface // request_human_attention notifications. type attentionSink interface { - notifyAttention(childID, reason string) + notifyAttention(processID, reason string) } -// toolHost adapts the running session + scratchpad store to the MCP -// ToolHost interface. SPEC §7 tools route through here. +// focusSink is implemented by uiState so MCP select_process can route +// to the existing focus-change path. +type focusSink interface { + focusProcess(processID string) +} + +// trustPrompter is implemented by uiState to surface the SPEC §7 trust +// confirmation modal when an agent first uses an untrusted command +// preset. The host returns `needs_trust` to the caller; the prompt +// completes asynchronously and the next call from the same caller +// succeeds (or fails again if the user denied). +type trustPrompter interface { + promptTrust(processID, presetName, reason string) +} + +// toolHost adapts the running session + scratchpad store + trust store +// to the MCP ToolHost interface. SPEC §7 tools route through here. type toolHost struct { - sess *Session - pads *scratchpad.Store - launcher *Launcher - presets preset.Set - policy *policy.Policy + sess *Session + pads *scratchpad.Store + launcher *Launcher + presets preset.Set + trust *trust.Store + sizeMu sync.Mutex defaultRow uint16 defaultCol uint16 - attention attentionSink + startedAtMu sync.Mutex + startedAt map[string]time.Time + + attention attentionSink + focus focusSink + prompter trustPrompter - // timersMu guards timers. timersMu sync.Mutex nextTimer int } +func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost { + return &toolHost{ + sess: sess, + pads: pads, + launcher: launcher, + presets: presets, + trust: tr, + defaultCol: cols, + defaultRow: rows, + startedAt: make(map[string]time.Time), + } +} + func (h *toolHost) SetSize(cols, rows uint16) { h.sizeMu.Lock() defer h.sizeMu.Unlock() @@ -52,120 +86,651 @@ func (h *toolHost) size() (uint16, uint16) { return h.defaultCol, h.defaultRow } -func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, pol *policy.Policy, cols, rows uint16) *toolHost { - return &toolHost{ - sess: sess, - pads: pads, - launcher: launcher, - presets: presets, - policy: pol, - defaultCol: cols, - defaultRow: rows, +// ResolveCallerIdentity maps an mcp-stdio greeting token to a +// process_id so subsequent tool calls on this connection know who's +// asking. +func (h *toolHost) ResolveCallerIdentity(identity string) string { + c := h.sess.FindChildByIdentity(identity) + if c == nil { + return "" } + return c.ID } -// PolicyCheck — SPEC §9 hook. Lets an orchestrator ask whether a -// prompt-looking string is safe to auto-answer. -func (h *toolHost) PolicyCheck(prompt string) string { - if h.policy == nil { - return string(policy.Unknown) +// CallerRole inspects the caller's parent. Top-level callers are +// orchestrators; descendants are sub-agents. Unknown callers are +// treated as orchestrators so they don't get silently denied — this +// matches SPEC §7's caller-role concept where the role enforces what +// the caller can do, not who they are. +func (h *toolHost) CallerRole(processID string) mcp.CallerRole { + if processID == "" { + return mcp.RoleOrchestrator } - return string(h.policy.Should(prompt)) + c := h.sess.FindChild(processID) + if c == nil { + return mcp.RoleOrchestrator + } + if c.ParentID == "" { + return mcp.RoleOrchestrator + } + return mcp.RoleSubAgent } -// Children — SPEC §7 list_children. The idle_ms field gives the -// orchestrator the SPEC §11 done-signal without needing to poll bytes. -func (h *toolHost) Children() []mcp.ChildInfo { - cs := h.sess.Children() - out := make([]mcp.ChildInfo, 0, len(cs)) - for _, c := range cs { - out = append(out, mcp.ChildInfo{ - ID: c.ID, - Name: c.Name, - Type: string(c.Kind), - Status: string(c.Status()), - ExitCode: c.ExitCode(), - IdleMS: c.IdleMS(), - ParentID: c.ParentID, - }) - } - return out -} +// ─────────────────────────────────────────────────────────────────── +// Lifecycle +// ─────────────────────────────────────────────────────────────────── -func (h *toolHost) Spawn(callerID, name string, argv []string, shell bool) (mcp.ChildInfo, error) { - if shell && len(argv) > 0 { - argv = []string{"sh", "-lc", strings.Join(argv, " ")} - } - parent := callerID - cols, rows := h.size() - c, err := h.sess.Spawn(name, KindProcess, argv, nil, cols, rows, parent) - if err != nil { - return mcp.ChildInfo{}, err - } - return mcp.ChildInfo{ - ID: c.ID, - Name: c.Name, - Type: string(c.Kind), - Status: string(c.Status()), - ParentID: c.ParentID, - }, nil -} - -func (h *toolHost) SpawnAgent(callerID, presetName, displayName, initialPrompt string) (mcp.ChildInfo, error) { +func (h *toolHost) SpawnAgent(callerID string, args mcp.SpawnAgentArgs) (mcp.ProcessInfo, error) { var p *preset.Preset for _, ap := range h.presets.Agents { - if ap.Name == presetName { + if ap.Name == args.Agent { p = ap break } } if p == nil { - return mcp.ChildInfo{}, fmt.Errorf("unknown agent preset %q", presetName) + return mcp.ProcessInfo{}, mcp.Errorf("unknown_agent", "unknown agent preset %q", args.Agent) } - if displayName == "" { - displayName = presetName + display := args.Name + if display == "" { + display = args.Agent } - c, err := h.launcher.LaunchAgent(p, displayName, initialPrompt, callerID) + c, err := h.launcher.LaunchAgent(p, display, args.AgentInstructions, callerID) if err != nil { - return mcp.ChildInfo{}, err + return mcp.ProcessInfo{}, err } - return mcp.ChildInfo{ - ID: c.ID, - Name: c.Name, - Type: string(c.Kind), - Status: string(c.Status()), - ParentID: c.ParentID, + h.recordStart(c.ID) + return h.processInfoOf(c), nil +} + +func (h *toolHost) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp.ProcessInfo, error) { + if args.Kind == "" { + args.Kind = "command" + } + if args.Kind != "command" && args.Kind != "terminal" { + return mcp.ProcessInfo{}, mcp.Errorf("invalid_kind", "spawn_process: kind must be 'command' or 'terminal'") + } + env := h.mergeEnv(args.Env) + if args.Kind == "terminal" { + c, err := h.launcher.LaunchTerminal(args.Argv, h.terminalName(args.Name), callerID, args.WorkingDir, env) + if err != nil { + return mcp.ProcessInfo{}, err + } + h.recordStart(c.ID) + return h.processInfoOf(c), nil + } + // kind == "command" + if args.Preset != "" { + if !h.trust.IsTrusted(args.Preset) { + h.askForTrust(callerID, args.Preset, "spawn_process") + return mcp.ProcessInfo{}, mcp.Errorf("needs_trust", "command preset %q is not trusted in this project — patterm has surfaced a confirmation; retry after the user accepts", args.Preset) + } + ps := h.commandPresetByName(args.Preset) + if ps == nil { + return mcp.ProcessInfo{}, mcp.Errorf("not_found", "command preset %q not found", args.Preset) + } + display := args.Name + if display == "" { + display = ps.Name + } + c, err := h.launcher.LaunchCommandPreset(ps, display, callerID) + if err != nil { + return mcp.ProcessInfo{}, err + } + h.recordStart(c.ID) + return h.processInfoOf(c), nil + } + if len(args.Argv) == 0 { + return mcp.ProcessInfo{}, mcp.Errorf("invalid_args", "spawn_process: either preset or argv required") + } + display := args.Name + if display == "" { + display = args.Argv[0] + } + c, err := h.launcher.LaunchCommandArgv(args.Argv, display, callerID, args.WorkingDir, env, args.Shell) + if err != nil { + return mcp.ProcessInfo{}, err + } + h.recordStart(c.ID) + return h.processInfoOf(c), nil +} + +func (h *toolHost) StartProcess(callerID, processID string) (mcp.ProcessInfo, error) { + c := h.sess.FindChild(processID) + if c == nil { + return mcp.ProcessInfo{}, mcp.Errorf("not_found", "no such process %q", processID) + } + if c.Kind != KindCommand { + return mcp.ProcessInfo{}, mcp.Errorf("wrong_kind", "start_process: only command entries can be started post-creation (this is %s)", c.Kind) + } + if c.IsLive() { + return h.processInfoOf(c), nil + } + if c.PresetRef != "" && !h.trust.IsTrusted(c.PresetRef) { + h.askForTrust(callerID, c.PresetRef, "start_process") + return mcp.ProcessInfo{}, mcp.Errorf("needs_trust", "command preset %q is not trusted in this project", c.PresetRef) + } + cols, rows := h.size() + if err := h.sess.Start(processID, cols, rows); err != nil { + return mcp.ProcessInfo{}, err + } + h.recordStart(processID) + return h.processInfoOf(c), nil +} + +func (h *toolHost) RestartProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) { + c := h.sess.FindChild(processID) + if c == nil { + return mcp.ProcessInfo{}, mcp.Errorf("not_found", "no such process %q", processID) + } + if c.Kind != KindCommand && !c.IsLive() { + return mcp.ProcessInfo{}, mcp.Errorf("wrong_kind", "restart_process: %s entries can only be restarted while live", c.Kind) + } + if c.Kind == KindCommand && c.PresetRef != "" && !h.trust.IsTrusted(c.PresetRef) { + h.askForTrust(callerID, c.PresetRef, "restart_process") + return mcp.ProcessInfo{}, mcp.Errorf("needs_trust", "command preset %q is not trusted in this project", c.PresetRef) + } + cols, rows := h.size() + if err := h.sess.Restart(processID, sig, cols, rows); err != nil { + return mcp.ProcessInfo{}, err + } + h.recordStart(processID) + return h.processInfoOf(c), nil +} + +func (h *toolHost) StopProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) { + c := h.sess.FindChild(processID) + if c == nil { + return mcp.ProcessInfo{}, mcp.Errorf("not_found", "no such process %q", processID) + } + if err := h.sess.Kill(processID, sig); err != nil { + return mcp.ProcessInfo{}, err + } + return h.processInfoOf(c), nil +} + +func (h *toolHost) CloseProcess(callerID, processID string) error { + c := h.sess.FindChild(processID) + if c == nil { + return mcp.Errorf("not_found", "no such process %q", processID) + } + _ = c // close removes by id; the lookup just validates existence. + return h.sess.Close(processID, syscall.SIGTERM) +} + +func (h *toolHost) RenameProcess(callerID, processID, name string) error { + c := h.sess.FindChild(processID) + if c == nil { + return mcp.Errorf("not_found", "no such process %q", processID) + } + if name == "" { + return mcp.Errorf("invalid_args", "rename_process: name required") + } + c.SetName(name) + return nil +} + +func (h *toolHost) SelectProcess(callerID, processID string) error { + if h.sess.FindChild(processID) == nil { + return mcp.Errorf("not_found", "no such process %q", processID) + } + if h.focus != nil { + h.focus.focusProcess(processID) + } + return nil +} + +// ─────────────────────────────────────────────────────────────────── +// Inspection +// ─────────────────────────────────────────────────────────────────── + +func (h *toolHost) ListProcesses(callerID, kindFilter string) []mcp.ProcessInfo { + cs := h.sess.Children() + out := make([]mcp.ProcessInfo, 0, len(cs)) + for _, c := range cs { + if kindFilter != "" && string(c.Kind) != kindFilter { + continue + } + out = append(out, h.processInfoOf(c)) + } + return out +} + +func (h *toolHost) GetProcessStatus(callerID, processID string) (mcp.ProcessStatus, error) { + c := h.sess.FindChild(processID) + if c == nil { + return mcp.ProcessStatus{}, mcp.Errorf("not_found", "no such process %q", processID) + } + info := h.processInfoOf(c) + st := mcp.ProcessStatus{ProcessInfo: info} + st.WorkingDir = c.WorkDir + st.Argv = append([]string(nil), c.Argv...) + if t := h.startedAtOf(c.ID); !t.IsZero() { + st.StartedAt = t.UTC().Format(time.RFC3339) + } + if em := c.Emulator(); em != nil { + if sc, err := em.ActiveScreen(); err == nil { + st.ActiveScreen = activeScreenName(sc) + } + if cur, err := em.Cursor(); err == nil { + st.Cursor = mcp.Cursor{X: int(cur.Col), Y: int(cur.Row)} + } + cols, rows := em.Size() + st.Cols, st.Rows = int(cols), int(rows) + } + st.ScreenVersion = c.ScreenVersion() + return st, nil +} + +func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) { + caller := h.WhoAmI(callerID) + processes := h.ListProcesses(callerID, "") + pads, _ := h.pads.List() + return mcp.ProjectStatus{ + Project: caller.Project, + Caller: caller, + Processes: processes, + Scratchpads: pads, }, nil } -// ReadOutput — SPEC §7. Grid uses the emulator's PlainText; stream uses -// the per-child ring buffer. For grid reads on agents we apply the -// preset's chrome_trim_hints (SPEC §10) so banners/input-box noise -// doesn't pollute orchestrator parsing. -func (h *toolHost) ReadOutput(callerID, childID, mode string, sinceOffset int) (string, int, error) { - c := h.sess.FindChild(childID) +func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (mcp.ProcessOutput, error) { + c := h.sess.FindChild(processID) if c == nil { - return "", 0, fmt.Errorf("no such child %q", childID) + return mcp.ProcessOutput{}, mcp.Errorf("not_found", "no such process %q", processID) + } + out := mcp.ProcessOutput{ + Mode: mode, + IdleMS: c.IdleMS(), + Status: string(c.Status()), + ScreenVersion: c.ScreenVersion(), + } + if em := c.Emulator(); em != nil { + if sc, err := em.ActiveScreen(); err == nil { + out.ActiveScreen = activeScreenName(sc) + } + if cur, err := em.Cursor(); err == nil { + out.Cursor = mcp.Cursor{X: int(cur.Col), Y: int(cur.Row)} + } + cols, rows := em.Size() + out.Cols, out.Rows = int(cols), int(rows) } switch mode { case "grid": - txt, err := c.em.PlainText() + em := c.Emulator() + if em == nil { + return out, nil + } + txt, err := em.PlainText() if err != nil { - return "", 0, err + return mcp.ProcessOutput{}, err } if c.Kind == KindAgent { - txt = applyChromeTrim(txt, h.chromeHintsFor(c.Name)) + txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef)) } - return txt, 0, nil + out.Content = txt + return out, nil case "stream": - b, off := c.StreamRead(int64(sinceOffset)) - return string(b), int(off), nil + b, end := c.StreamRead(sinceOffset) + out.Content = stripANSI(string(b)) + out.NewOffset = end + return out, nil default: - return "", 0, fmt.Errorf("unknown read_output mode %q", mode) + return mcp.ProcessOutput{}, mcp.Errorf("invalid_args", "unknown mode %q (want grid|stream)", mode) } } +func (h *toolHost) GetProcessRawOutput(callerID, processID string, sinceOffset int64) (mcp.RawOutput, error) { + c := h.sess.FindChild(processID) + if c == nil { + return mcp.RawOutput{}, mcp.Errorf("not_found", "no such process %q", processID) + } + b, end := c.StreamRead(sinceOffset) + return mcp.RawOutput{ + Content: string(b), + NewOffset: end, + Status: string(c.Status()), + }, nil +} + +func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) { + c := h.sess.FindChild(processID) + if c == nil { + return mcp.SearchResult{}, mcp.Errorf("not_found", "no such process %q", processID) + } + re, err := regexp.Compile(pattern) + if err != nil { + return mcp.SearchResult{}, mcp.Errorf("invalid_args", "regex: %v", err) + } + b, _ := c.StreamRead(0) + text := string(b) + if kind == "rendered" { + text = stripANSI(text) + } + lines := strings.Split(text, "\n") + matches := make([]mcp.SearchMatch, 0, limit) + truncated := false + for i, line := range lines { + if re.MatchString(line) { + if len(matches) >= limit { + truncated = true + break + } + matches = append(matches, mcp.SearchMatch{LineNo: i + 1, Text: line}) + } + } + return mcp.SearchResult{Matches: matches, Truncated: truncated}, nil +} + +func (h *toolHost) WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (bool, string, error) { + c := h.sess.FindChild(processID) + if c == nil { + return false, "", mcp.Errorf("not_found", "no such process %q", processID) + } + re, err := regexp.Compile(pattern) + if err != nil { + return false, "", mcp.Errorf("invalid_args", "regex: %v", err) + } + if scope == "" { + scope = "grid" + } + deadline := time.Now().Add(time.Duration(timeoutSeconds * float64(time.Second))) + tick := time.NewTicker(50 * time.Millisecond) + defer tick.Stop() + for { + text := "" + switch scope { + case "grid": + if em := c.Emulator(); em != nil { + if t, err := em.PlainText(); err == nil { + text = t + } + } + case "scrollback": + b, _ := c.StreamRead(0) + text = stripANSI(string(b)) + default: + return false, "", mcp.Errorf("invalid_args", "unknown scope %q (want grid|scrollback)", scope) + } + if m := re.FindString(text); m != "" { + return true, m, nil + } + if time.Now().After(deadline) { + return false, "", nil + } + <-tick.C + if !c.IsLive() && c.Status() != StatusStopped { + return false, "", nil + } + } +} + +func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) { + c := h.sess.FindChild(processID) + if c == nil { + return nil, mcp.Errorf("not_found", "no such process %q", processID) + } + src := c.Ports() + out := make([]mcp.PortSighting, 0, len(src)) + for _, p := range src { + out = append(out, mcp.PortSighting(p)) + } + return out, nil +} + +// ─────────────────────────────────────────────────────────────────── +// I/O +// ─────────────────────────────────────────────────────────────────── + +func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendInputResult, error) { + c := h.sess.FindChild(args.ProcessID) + if c == nil { + return mcp.SendInputResult{}, mcp.Errorf("not_found", "no such process %q", args.ProcessID) + } + if !c.IsLive() { + return mcp.SendInputResult{}, fmt.Errorf("process %q is %s", args.ProcessID, c.Status()) + } + payload, err := encodeInput(args) + if err != nil { + return mcp.SendInputResult{}, err + } + if err := c.InjectAsOrchestrator(payload); err != nil { + return mcp.SendInputResult{}, err + } + res := mcp.SendInputResult{OK: true} + if args.WaitMS > 0 { + mode := args.TailMode + if mode == "" { + mode = "stream" + } + if mode != "none" { + time.Sleep(time.Duration(args.WaitMS) * time.Millisecond) + tail, err := h.GetProcessOutput(callerID, args.ProcessID, mode, 0) + if err == nil { + res.Tail = &tail + } + } + } + return res, nil +} + +func encodeInput(args mcp.SendInputArgs) ([]byte, error) { + switch args.Kind { + case "", "text": + submit := true + if args.Submit != nil { + submit = *args.Submit + } + out := []byte(args.Text) + if submit { + out = append(out, '\n') + } + return out, nil + case "paste": + // Bracketed paste sentinels — SPEC §7. Always sent; emulator + // handles falling back when the target hasn't enabled bracketed + // paste mode (the sentinels just print). + return []byte("\x1b[200~" + args.Text + "\x1b[201~"), nil + case "key": + return encodeKey(args.Key) + } + return nil, mcp.Errorf("invalid_args", "send_input: unknown kind %q", args.Kind) +} + +// encodeKey maps a SPEC §7 named key to bytes. We use legacy xterm +// escapes here; the emulator's key encoder is concerned with what +// arrives FROM the user, not bytes sent TO the child. Kitty keyboard +// protocol support is a future refinement. +func encodeKey(key string) ([]byte, error) { + switch key { + case "enter": + return []byte{'\r'}, nil + case "tab": + return []byte{'\t'}, nil + case "escape": + return []byte{0x1b}, nil + case "backspace": + return []byte{0x7f}, nil + case "ctrl-c": + return []byte{0x03}, nil + case "ctrl-d": + return []byte{0x04}, nil + case "up": + return []byte("\x1b[A"), nil + case "down": + return []byte("\x1b[B"), nil + case "right": + return []byte("\x1b[C"), nil + case "left": + return []byte("\x1b[D"), nil + case "home": + return []byte("\x1b[H"), nil + case "end": + return []byte("\x1b[F"), nil + case "page-up": + return []byte("\x1b[5~"), nil + case "page-down": + return []byte("\x1b[6~"), nil + case "f1": + return []byte("\x1bOP"), nil + case "f2": + return []byte("\x1bOQ"), nil + case "f3": + return []byte("\x1bOR"), nil + case "f4": + return []byte("\x1bOS"), nil + case "f5": + return []byte("\x1b[15~"), nil + case "f6": + return []byte("\x1b[17~"), nil + case "f7": + return []byte("\x1b[18~"), nil + case "f8": + return []byte("\x1b[19~"), nil + case "f9": + return []byte("\x1b[20~"), nil + case "f10": + return []byte("\x1b[21~"), nil + case "f11": + return []byte("\x1b[23~"), nil + case "f12": + return []byte("\x1b[24~"), nil + } + return nil, mcp.Errorf("invalid_args", "unknown key %q", key) +} + +// ─────────────────────────────────────────────────────────────────── +// Coordination +// ─────────────────────────────────────────────────────────────────── + +// SendMessage delivers a tagged message into the target's PTY. +// Direction is inferred from the caller↔target relationship (SPEC §7 +// send_message): parent→child → `[orchestrator]`; child→parent → +// `[sub-agent:]`; everything else (siblings, unrelated) → +// `not_related`. +func (h *toolHost) SendMessage(callerID, targetID, message string) error { + target := h.sess.FindChild(targetID) + if target == nil { + return mcp.Errorf("not_found", "no such process %q", targetID) + } + caller := h.sess.FindChild(callerID) + line, err := classifySendMessage(caller, target, callerID, message) + if err != nil { + return err + } + return target.InjectAsOrchestrator([]byte(line)) +} + +// classifySendMessage is the pure routing-decision helper extracted +// from SendMessage so unit tests can exercise the direction inference +// without spinning up real PTYs. +// +// The caller pointer may be nil — that's the case when the request +// arrives over an MCP connection without a resolved patterm identity +// (a top-level tool client). In that case we treat the caller as an +// implicit orchestrator and accept the message if the target is a +// top-level process. +func classifySendMessage(caller, target *Child, callerID, message string) (string, error) { + if target.ID == callerID { + return "", mcp.Errorf("not_related", "send_message: cannot send to self") + } + if caller != nil && target.ParentID == caller.ID { + return "[orchestrator] " + message + "\n", nil + } + if caller != nil && caller.ParentID == target.ID { + return fmt.Sprintf("[sub-agent:%s] %s\n", caller.DisplayName(), message), nil + } + if caller == nil && target.ParentID == "" { + return "[orchestrator] " + message + "\n", nil + } + return "", mcp.Errorf("not_related", "send_message: %q is neither parent nor child of caller (siblings must route through the parent in v1)", target.ID) +} + +func (h *toolHost) RequestHumanAttention(callerID, processID, reason string) error { + if h.attention != nil { + h.attention.notifyAttention(processID, reason) + } + return nil +} + +func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (string, error) { + caller := h.sess.FindChild(callerID) + if caller == nil { + return "", mcp.Errorf("not_found", "caller %q not known to patterm", callerID) + } + h.timersMu.Lock() + h.nextTimer++ + id := fmt.Sprintf("t%d", h.nextTimer) + h.timersMu.Unlock() + if label == "" { + label = id + } + go func() { + time.Sleep(time.Duration(seconds * float64(time.Second))) + if !caller.IsLive() { + return + } + line := fmt.Sprintf("[system] Your timer [%s] has completed.\n", label) + _ = caller.InjectAsOrchestrator([]byte(line)) + }() + return id, nil +} + +// ─────────────────────────────────────────────────────────────────── +// Scratchpads / Meta +// ─────────────────────────────────────────────────────────────────── + +func (h *toolHost) Scratchpads() *scratchpad.Store { return h.pads } + +func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI { + w := mcp.WhoAmI{ + ProcessID: callerID, + Role: h.CallerRole(callerID), + Project: mcp.ProjectMeta{ + Path: h.sess.projectDir, + Key: h.sess.projectKey, + }, + AvailableTools: availableToolsForRole(h.CallerRole(callerID)), + } + if c := h.sess.FindChild(callerID); c != nil { + w.Name = c.DisplayName() + w.ParentProcessID = c.ParentID + } + return w +} + +func (h *toolHost) Help(callerID, topic string) mcp.HelpResponse { + return helpFor(topic) +} + +// ─────────────────────────────────────────────────────────────────── +// Internal helpers +// ─────────────────────────────────────────────────────────────────── + +func (h *toolHost) processInfoOf(c *Child) mcp.ProcessInfo { + info := mcp.ProcessInfo{ + ID: c.ID, + Name: c.DisplayName(), + Kind: string(c.Kind), + Status: string(c.Status()), + ParentProcessID: c.ParentID, + IdleMS: c.IdleMS(), + } + if !c.IsLive() && c.Status() != StatusStopped { + ec := c.ExitCode() + info.ExitCode = &ec + } + if c.Kind == KindCommand && c.PresetRef != "" { + t := h.trust.IsTrusted(c.PresetRef) + info.Trusted = &t + } + return info +} + func (h *toolHost) chromeHintsFor(presetName string) []string { + if presetName == "" { + return nil + } for _, p := range h.presets.Agents { if p.Name == presetName { return p.ChromeTrimHints @@ -174,9 +739,54 @@ func (h *toolHost) chromeHintsFor(presetName string) []string { return nil } -// applyChromeTrim deletes every line that matches any of the given -// regexes. Compiled regexes are cached per call; the agent preset list -// is small enough that recompilation cost is negligible. +func (h *toolHost) commandPresetByName(name string) *preset.Preset { + for _, p := range h.presets.Processes { + if p.Name == name { + return p + } + } + return nil +} + +func (h *toolHost) terminalName(name string) string { + if name != "" { + return name + } + return "terminal" +} + +func (h *toolHost) mergeEnv(extra map[string]string) []string { + if len(extra) == 0 { + return nil + } + env := h.sess.ChildEnv() + for k, v := range extra { + env = append(env, k+"="+v) + } + return env +} + +func (h *toolHost) recordStart(id string) { + h.startedAtMu.Lock() + defer h.startedAtMu.Unlock() + h.startedAt[id] = time.Now() +} + +func (h *toolHost) startedAtOf(id string) time.Time { + h.startedAtMu.Lock() + defer h.startedAtMu.Unlock() + return h.startedAt[id] +} + +func (h *toolHost) askForTrust(callerID, presetName, reason string) { + if h.prompter == nil { + return + } + h.prompter.promptTrust(callerID, presetName, reason) +} + +// applyChromeTrim deletes lines matching any of the given regexes. +// SPEC §10 chrome_trim_hints. func applyChromeTrim(txt string, hints []string) string { if len(hints) == 0 { return txt @@ -208,128 +818,114 @@ func applyChromeTrim(txt string, hints []string) string { return strings.Join(out, "\n") } -func (h *toolHost) SendInput(callerID, childID string, payload []byte, appendNewline bool) error { - if appendNewline { - payload = append(payload, '\n') +func activeScreenName(s pkgvt.Screen) string { + switch s { + case pkgvt.ScreenAlternate: + return "alternate" + default: + return "main" } - c := h.sess.FindChild(childID) - if c == nil { - return fmt.Errorf("no such child %q", childID) - } - if c.Status() != StatusRunning { - return fmt.Errorf("child %q is %s", childID, c.Status()) - } - return c.InjectAsOrchestrator(payload) } -func (h *toolHost) Kill(callerID, childID string, sig syscall.Signal) error { - return h.sess.Kill(childID, sig) +// ansiRegexp strips CSI escape sequences and common single-character +// controls (BEL, OSC terminators) from the stream. The vt emulator +// already handles full rendering for grid mode; this is only for +// stream-mode ANSI-stripped output. +var ansiRegexp = regexp.MustCompile(`\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\x1b[\x40-\x5f]|\x07`) + +func stripANSI(s string) string { + return ansiRegexp.ReplaceAllString(s, "") } -// SendMessageTo — SPEC §7 + §8. Injects "[orchestrator] \n" into -// the target's PTY. -func (h *toolHost) SendMessageTo(callerID, targetID, message string) error { - target := h.sess.FindChild(targetID) - if target == nil { - return fmt.Errorf("no such child %q", targetID) +// availableToolsForRole — SPEC §7 whoami exposes the list a caller can +// invoke from its current role. Sub-agents lose `spawn_agent` (§8 +// two-level-tree rule). +func availableToolsForRole(role mcp.CallerRole) []string { + tools := []string{ + "spawn_process", "start_process", "restart_process", "stop_process", + "close_process", "rename_process", "select_process", + "list_processes", "get_process_status", "get_project_status", + "get_process_output", "get_process_raw_output", "search_output", + "wait_for_pattern", "get_process_ports", + "send_input", "send_message", "request_human_attention", "timer_wait", + "scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", + "whoami", "help", } - line := "[orchestrator] " + message + "\n" - return target.InjectAsOrchestrator([]byte(line)) + if role == mcp.RoleOrchestrator { + tools = append([]string{"spawn_agent"}, tools...) + } + return tools } -// ReportToParent — SPEC §8. Injects "[sub-agent:] \n" into -// the calling agent's parent pane. -func (h *toolHost) ReportToParent(callerID, message string) error { - caller := h.sess.FindChild(callerID) - if caller == nil { - return fmt.Errorf("caller %q not known to patterm", callerID) - } - if caller.ParentID == "" { - return fmt.Errorf("caller %q has no parent", callerID) - } - parent := h.sess.FindChild(caller.ParentID) - if parent == nil { - return fmt.Errorf("parent %q gone", caller.ParentID) - } - line := fmt.Sprintf("[sub-agent:%s] %s\n", caller.Name, message) - return parent.InjectAsOrchestrator([]byte(line)) -} - -// TimerWait — SPEC §7. Returns immediately with a timer_id. After -// seconds elapse, injects "[system] Your timer [