Files
BlackBean/pigo/internal/cli/headless/headless.go
T
2026-08-14 23:41:57 +08:00

206 lines
8.1 KiB
Go

// This file is the headless run driver: the print / stream-json run path
// (US-020) extracted from the CLI dispatch seam (#363). dispatch resolves the
// output mode and the run environment, then hands off to Run, which wires the
// session, prompt, thinking level, and provider credentials into a
// runtime.HeadlessConfig and executes one run. Plugin slash commands and output
// mode parsing live here because they are specific to the headless path.
package headless
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/cli/ui"
"github.com/smallnest/pigo/internal/plugin"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
)
// RunParams carries the resolved inputs for one headless run. Mode and Env are
// resolved by the caller (dispatch) — Mode via ParseOutputMode, Env via
// run.SetupEnv — so their distinct exit codes stay at the call site; Run owns
// the rest of the run lifecycle.
type RunParams struct {
Mode runtime.HeadlessMode
Env run.Env
Prompt string
Model string
APIKey string
ThinkingLevel string
ResumeID string
}
// Run executes one headless run over p.Prompt, writing agent output to out and
// diagnostics to errOut, and returns a process exit code (0 = success). The run
// is backed by a session so its id appears in the first stream-json event and it
// can be resumed with --resume/--continue; a resumed session seeds its prior
// messages ahead of the new prompt.
func Run(ctx context.Context, p RunParams, out, errOut io.Writer) int {
env := p.Env
// Best-effort plugin slash-command support in headless mode: if the prompt is
// a "/cmd ..." naming a plugin command, invoke it, print its notifications to
// errOut, and use the returned prompt for this run (appending the raw args if
// the command produced no prompt). Headless has no turn injection, so
// appending the returned prompt is the accepted behavior. A non-plugin prompt
// or unknown command is left untouched.
headlessPrompt := resolveHeadlessPluginCommand(p.Prompt, env.Plugins, errOut)
promptContent, err := ui.BuildUserContent(headlessPrompt)
if err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 1
}
// Back the headless run with a session so its id appears in the first
// stream-json event and the run can be resumed with --resume/--continue,
// matching the interactive REPL and pi/Claude Code. A resumed session seeds
// its prior messages ahead of the new prompt.
priorMsgs, hs, err := openHeadlessSession(p.ResumeID, p.Model, env.ProviderName, env.SysPrompt)
if err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 1
}
messages := append(priorMsgs, agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: promptContent})
agentCtx := &agentcore.AgentContext{
SystemPrompt: hs.header.SystemPrompt,
Messages: messages,
Tools: env.Tools,
}
// Resolve the effective reasoning-effort level through the layered config
// chain (default < global < project < env < --thinking-level flag).
thinking, err := run.ResolveThinkingLevel(p.ThinkingLevel)
if err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 2
}
// Resolve the API key by provider name from the environment (never logged).
// An explicit --api-key overrides env/config for the resolved provider.
creds := provider.NewCredentialStore(nil)
creds.SetOverride(env.ProviderName, p.APIKey)
runCfg := run.NewConfig(p.Model, env.ProviderName, thinking, env.Provider, creds, run.ToolRegistry(env.Tools), run.TodoReminders(env.Tools))
runCfg.SessionID = hs.header.ID
// Route auto-compaction checkpoints to the shared memory root so a rebuild can
// recover the pre-watermark prefix (no-op when memory is disabled → empty root).
runCfg.MemoryRoot = run.MemoryRootFromTools(env.Tools)
// Wire hooks uniformly with every other driver (#425): resolve the trust-gated
// hook set, install the tool-execution + Stop seams, dispatch SessionStart, and
// chain the SessionEnd/PreCompact observer onto the plugin event notifier. A
// malformed hook layer is a config error (exit 2), matching thinking-level.
source := "startup"
if p.ResumeID != "" {
source = "resume"
}
set, herr := run.ResolveHookSet(env.Cwd, run.Trusted(env.Cwd))
if herr != nil {
fmt.Fprintf(errOut, "pigo: %v\n", herr)
return 2
}
hookDeps := run.HookDeps{SessionID: hs.header.ID, ProjectDir: env.Cwd, WarnLog: errOut}
// Deliver agent lifecycle events to any subscribed plugin (US-017, #133).
// NewEventNotifier returns nil when no plugin subscribes, so the base handler
// stays nil in the common no-plugin case.
var baseOnEvent func(agentcore.AgentEvent)
if n := plugin.NewEventNotifier(env.Plugins, errOut); n != nil {
baseOnEvent = n.Handle
}
d, onEvent := run.InstallDriverHooks(ctx, &runCfg, set, hookDeps, source, baseOnEvent)
// UserPromptSubmit runs before the prompt is handed to the loop: a block aborts
// the headless run non-zero; additionalContext is injected into this run only.
if d != nil {
if block, reason := run.DispatchUserPromptSubmit(ctx, d, &runCfg, hookDeps, headlessPrompt); block {
fmt.Fprintf(errOut, "pigo: prompt blocked by hook: %s\n", reason)
return 1
}
}
cfg := runtime.HeadlessConfig{
Mode: p.Mode,
Out: out,
Run: runCfg,
}
cfg.OnEvent = onEvent
runErr := runtime.RunHeadless(ctx, agentCtx, cfg)
// Persist the run's messages regardless of run outcome so a partial run is
// still resumable; a persistence failure is reported but does not mask a run
// error.
if perr := hs.persist(agentCtx); perr != nil {
fmt.Fprintf(errOut, "pigo: warning: could not persist session %s: %v\n", hs.header.ID, perr)
}
if runErr != nil {
fmt.Fprintf(errOut, "pigo: %v\n", runErr)
return 1
}
return 0
}
// resolveHeadlessPluginCommand gives the headless / print path best-effort
// support for plugin slash commands. When prompt is a "/cmd ..." naming a
// plugin command (from mgr.Commands()), it invokes the command, prints each
// returned notification to notifyOut, and returns the command's returned Prompt
// as the run's prompt. If the command returns no prompt, the raw argument text
// is used instead (so a bare "/cmd" with only notifications still runs
// something sensible rather than an empty prompt). Any other input — a
// non-command, an unknown command, or a call error — leaves prompt unchanged so
// the normal headless run proceeds. mgr may be nil (no plugins).
//
// Headless has no turn-injection loop, so "inject the returned prompt" degrades
// to "use the returned prompt for this run", which the acceptance criteria
// permit.
func resolveHeadlessPluginCommand(prompt string, mgr *plugin.Manager, notifyOut io.Writer) string {
if mgr == nil || !strings.HasPrefix(strings.TrimLeft(prompt, " \t"), "/") {
return prompt
}
trimmed := strings.TrimLeft(prompt, " \t")[1:]
name := trimmed
args := ""
if i := strings.IndexAny(trimmed, " \t"); i >= 0 {
name = trimmed[:i]
args = strings.TrimSpace(trimmed[i+1:])
}
for _, pc := range mgr.Commands() {
if pc.Spec.Name != name {
continue
}
// Encode the raw arg text as a JSON string (never null), matching the
// host's CommandCallParams.Args contract.
raw, _ := json.Marshal(args)
res, err := pc.Plugin.CallCommand(context.Background(), name, json.RawMessage(raw))
if err != nil {
fmt.Fprintf(notifyOut, "pigo: plugin command %q failed: %v\n", name, err)
return prompt
}
for _, n := range res.Notifications {
if n.Type != "" {
fmt.Fprintf(notifyOut, "[%s] %s\n", n.Type, n.Message)
} else {
fmt.Fprintln(notifyOut, n.Message)
}
}
if res.Prompt != "" {
return res.Prompt
}
return args
}
return prompt
}
// ParseOutputMode maps the --output-format flag onto a HeadlessMode, erroring on
// an unknown value.
func ParseOutputMode(outputFmt string) (runtime.HeadlessMode, error) {
switch outputFmt {
case "text", "":
return runtime.PrintMode, nil
case "stream-json":
return runtime.StreamJSONMode, nil
default:
return 0, fmt.Errorf("unknown --output-format %q (want text|stream-json)", outputFmt)
}
}