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, }, 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() 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) } // 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, " ") }