package app import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" "time" "github.com/harrybrwn/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) } // Mint a per-spawn MCP config file pointing at the mcp-stdio proxy // with the new child's identity. We don't know the identity until // we've created the child, but the child needs the env/argv at // creation time — so we reserve the identity by pre-creating the // MCP config with a placeholder, then patching it post-spawn. identity, mcpConfigPath, err := l.writeMCPConfig() if err != nil { return nil, err } 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) } 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) } env = append(env, p.MCPInjection.Var+"="+mcpConfigPath) case "config_file": // SPEC §10 mentions merging into an external config file. We // expose the config_path via an env var the user can read // at preset-creation time; full merge is deferred. env = append(env, "PATTERM_MCP_CONFIG="+mcpConfigPath) default: 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, }, cols, rows) if err != nil { _ = os.Remove(mcpConfigPath) 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 } _ = c.InjectAsOrchestrator([]byte(initialPrompt + "\n")) }() 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() 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) { identity = mintIdentity() 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 identity, 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, " ") }