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.spawnWithIdentity(displayName, KindAgent, argv, env, cols, rows, parentID, identity) 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 } // 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) } 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, "") } 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, " ") }