315 lines
9.3 KiB
Go
315 lines
9.3 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hjbdev/patterm/internal/persist"
|
|
"github.com/hjbdev/patterm/internal/preset"
|
|
)
|
|
|
|
// Launcher knows how to turn a preset into a running child. Both the
|
|
// palette and the MCP spawn_agent tool route through here so MCP
|
|
// injection happens consistently. SPEC §10.
|
|
type Launcher struct {
|
|
sess *Session
|
|
mcpSocket string
|
|
bin string // path to this binary (for the mcp-stdio subcommand)
|
|
sizeMu sync.Mutex
|
|
cols, rows uint16
|
|
}
|
|
|
|
func NewLauncher(sess *Session, mcpSocket string, cols, rows uint16) *Launcher {
|
|
bin, err := os.Executable()
|
|
if err != nil {
|
|
bin = "patterm"
|
|
}
|
|
return &Launcher{sess: sess, mcpSocket: mcpSocket, bin: bin, cols: cols, rows: rows}
|
|
}
|
|
|
|
func (l *Launcher) SetSize(cols, rows uint16) {
|
|
l.sizeMu.Lock()
|
|
defer l.sizeMu.Unlock()
|
|
l.cols, l.rows = cols, rows
|
|
}
|
|
|
|
func (l *Launcher) size() (uint16, uint16) {
|
|
l.sizeMu.Lock()
|
|
defer l.sizeMu.Unlock()
|
|
return l.cols, l.rows
|
|
}
|
|
|
|
// LaunchAgent spawns the agent preset, applies the preset's MCP
|
|
// injection, waits for the ready signal, and types initial_prompt into
|
|
// the PTY. SPEC §7 spawn_agent, §8 conversation protocol.
|
|
func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, parentID string) (*Child, error) {
|
|
if p.Kind != preset.KindAgent {
|
|
return nil, fmt.Errorf("launch: %q is not an agent preset", p.Name)
|
|
}
|
|
argv := append([]string(nil), p.Argv...)
|
|
env := l.sess.ChildEnv()
|
|
for k, v := range p.Env {
|
|
env = append(env, k+"="+v)
|
|
}
|
|
|
|
identity := mintIdentity()
|
|
var cleanupPaths []string
|
|
cleanup := func() {
|
|
for _, path := range cleanupPaths {
|
|
_ = os.RemoveAll(path)
|
|
}
|
|
}
|
|
|
|
if p.MCPInjection != nil {
|
|
switch p.MCPInjection.Kind {
|
|
case "flag":
|
|
if p.MCPInjection.Flag == "" {
|
|
return nil, fmt.Errorf("preset %s: mcp_injection.flag required for kind=flag", p.Name)
|
|
}
|
|
mcpConfigPath, err := l.writeMCPConfig(identity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cleanupPaths = append(cleanupPaths, mcpConfigPath)
|
|
argv = append(argv, p.MCPInjection.Flag, mcpConfigPath)
|
|
case "env_var":
|
|
if p.MCPInjection.Var == "" {
|
|
return nil, fmt.Errorf("preset %s: mcp_injection.var required for kind=env_var", p.Name)
|
|
}
|
|
mcpConfigPath, err := l.writeMCPConfig(identity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cleanupPaths = append(cleanupPaths, mcpConfigPath)
|
|
env = append(env, p.MCPInjection.Var+"="+mcpConfigPath)
|
|
case "config_file":
|
|
// Merge patterm's MCP entry into a vendored copy of the
|
|
// user's existing config file, then point the child at the
|
|
// vendored copy via the preset's home_var. The real config
|
|
// file is never modified.
|
|
envAssign, homeDir, mErr := mcpConfigMerge(p, p.MCPInjection, identity, l.bin, l.mcpSocket)
|
|
if mErr != nil {
|
|
return nil, mErr
|
|
}
|
|
cleanupPaths = append(cleanupPaths, homeDir)
|
|
env = append(env, envAssign)
|
|
case "cli_override":
|
|
// Inline -c key=value overrides for agents that accept
|
|
// them (codex's `-c mcp_servers.patterm.command=...`). No
|
|
// filesystem footprint, so the user's real config and auth
|
|
// are untouched.
|
|
extra, err := mcpCLIOverrideArgs(p, p.MCPInjection, identity, l.bin, l.mcpSocket)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
argv = append(argv, extra...)
|
|
case "config_env":
|
|
// Read the user's config, merge patterm in, and pass the
|
|
// merged document inline via an env var (opencode's
|
|
// OPENCODE_CONFIG_CONTENT). Nothing is written to disk and
|
|
// XDG_CONFIG_HOME stays as the user set it.
|
|
assignment, err := mcpConfigEnv(p, p.MCPInjection, identity, l.bin, l.mcpSocket)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
env = append(env, assignment)
|
|
default:
|
|
cleanup()
|
|
return nil, fmt.Errorf("preset %s: unknown mcp_injection.kind %q", p.Name, p.MCPInjection.Kind)
|
|
}
|
|
}
|
|
|
|
// Spawn with the chosen identity.
|
|
cols, rows := l.size()
|
|
c, err := l.sess.Spawn(SpawnSpec{
|
|
Kind: KindAgent,
|
|
Argv: argv,
|
|
Env: env,
|
|
Name: displayName,
|
|
ParentID: parentID,
|
|
PresetRef: p.Name,
|
|
Identity: identity,
|
|
CleanupPaths: cleanupPaths,
|
|
IdleDetection: resolveIdleDetection(p.IdleDetection),
|
|
}, cols, rows)
|
|
if err != nil {
|
|
cleanup()
|
|
return nil, err
|
|
}
|
|
|
|
// Wait for the preset's ready signal, then type the initial prompt.
|
|
idle := time.Duration(1000) * time.Millisecond
|
|
if p.ReadySignal != nil && p.ReadySignal.IdleMS > 0 {
|
|
idle = time.Duration(p.ReadySignal.IdleMS) * time.Millisecond
|
|
}
|
|
go func() {
|
|
waitForIdle(c, idle, 30*time.Second)
|
|
if initialPrompt == "" {
|
|
return
|
|
}
|
|
// InjectAsOrchestrator splits Enter onto its own PTY write so
|
|
// claude / codex / opencode treat the CR as a key event
|
|
// rather than the tail end of a multi-byte paste.
|
|
_ = c.InjectAsOrchestrator([]byte(initialPrompt + "\r"))
|
|
}()
|
|
return c, nil
|
|
}
|
|
|
|
// 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()
|
|
c, err := l.sess.Spawn(SpawnSpec{
|
|
Kind: KindCommand,
|
|
Argv: p.ResolvedArgv(),
|
|
Env: env,
|
|
Name: displayName,
|
|
ParentID: parentID,
|
|
WorkDir: p.WorkingDir,
|
|
PresetRef: p.Name,
|
|
IdleDetection: resolveIdleDetection(p.IdleDetection),
|
|
}, cols, rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// RestoreCommand re-spawns a persisted top-level command entry. If
|
|
// the entry has a PresetRef and the preset still exists, the spawn
|
|
// goes through LaunchCommandPreset (so preset.Env / WorkingDir stay
|
|
// authoritative). Otherwise the saved argv runs directly via
|
|
// LaunchCommandArgv with shell=false — entries that were originally
|
|
// `shell: true` were already wrapped into `["sh","-lc",...]` before
|
|
// persistence, so re-wrapping isn't needed.
|
|
//
|
|
// Returns the freshly minted Child. The caller is responsible for
|
|
// setting auto-restart back on the returned entry.
|
|
func (l *Launcher) RestoreCommand(e persist.Entry, presets preset.Set) (*Child, error) {
|
|
if e.PresetRef != "" {
|
|
for _, p := range presets.Processes {
|
|
if p.Name == e.PresetRef {
|
|
return l.LaunchCommandPreset(p, e.Name, "")
|
|
}
|
|
}
|
|
// Preset has been deleted since the entry was saved. Fall
|
|
// through to argv-based restore using whatever the saved
|
|
// command looked like at the time.
|
|
}
|
|
if len(e.Argv) == 0 {
|
|
return nil, fmt.Errorf("restore: entry %s has no argv", e.ID)
|
|
}
|
|
return l.LaunchCommandArgv(e.Argv, e.Name, "", e.WorkDir, nil, false)
|
|
}
|
|
|
|
// 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 string) (string, error) {
|
|
dir, err := preset.ConfigDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dir = filepath.Join(dir, "mcp")
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return "", err
|
|
}
|
|
path := filepath.Join(dir, identity+".json")
|
|
cfg := map[string]any{
|
|
"mcpServers": map[string]any{
|
|
"patterm": map[string]any{
|
|
"command": l.bin,
|
|
"args": []string{"mcp-stdio", "--socket", l.mcpSocket, "--identity", identity},
|
|
},
|
|
},
|
|
}
|
|
body, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
body = append(body, '\n')
|
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
|
return "", err
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// waitForIdle polls the child's IdleMS until it exceeds idle, or until
|
|
// max elapses.
|
|
func waitForIdle(c *Child, idle, max time.Duration) {
|
|
deadline := time.Now().Add(max)
|
|
tick := time.NewTicker(100 * time.Millisecond)
|
|
defer tick.Stop()
|
|
for {
|
|
<-tick.C
|
|
if c.Status() != StatusRunning {
|
|
return
|
|
}
|
|
if c.IdleMS() >= idle.Milliseconds() && c.lastWriteNS.Load() != 0 {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// joinArgs flattens an argv slice into a single line (used for display
|
|
// hints).
|
|
func joinArgs(argv []string) string { return strings.Join(argv, " ") }
|