Files
2026-08-14 23:41:57 +08:00

186 lines
6.6 KiB
Go

// Package headless drives pigo's non-interactive run paths: the print /
// stream-json headless run, the session listing/resume helpers, and the
// process-isolated sub-agent JSON-RPC server (--subagent-rpc).
//
// This file gives headless / stream-json runs the same session persistence and
// resume the interactive REPL has (cmd/pigo/interactive.go). Before this, a
// headless run built an in-memory AgentContext and threw it away on exit, so
// `--output-format stream-json` emitted no session id and `--resume`/`--continue`
// only worked in the REPL.
//
// Now a headless run is backed by a session file: resuming seeds the context
// from a prior session (and re-anchors the branch leaf), a fresh run creates a
// new session, and in both cases the run's newly produced messages are appended
// after it completes. The session id is threaded into the run so it appears in
// the first stream-json event (mirrors pi/Claude Code) and can be passed back via
// --resume to continue the run.
package headless
import (
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/session"
)
// SessionStore returns the session store rooted at ~/.pigo/sessions (or under
// PIGO_HOME when set), creating the directory on first use. It is shared by the
// headless run path and the interactive REPL.
func SessionStore() (*session.Store, error) {
dir := os.Getenv("PIGO_HOME")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("resolve home dir: %w", err)
}
dir = filepath.Join(home, ".pigo")
}
return session.NewStore(filepath.Join(dir, "sessions"))
}
// PrintSessions prints the stored sessions, most-recent first, to out.
func PrintSessions(out io.Writer) error {
store, err := SessionStore()
if err != nil {
return err
}
headers, err := store.List()
if err != nil {
return err
}
if len(headers) == 0 {
fmt.Fprintln(out, "no sessions")
return nil
}
for _, h := range headers {
fmt.Fprintf(out, "%s\t%s\t%s\n", h.ID, h.UpdatedAt.Local().Format("2006-01-02 15:04"), h.Model)
}
return nil
}
// MostRecentSessionID returns the id of the most recently updated session, or
// "" if there are none.
func MostRecentSessionID() (string, error) {
store, err := SessionStore()
if err != nil {
return "", err
}
headers, err := store.List()
if err != nil {
return "", err
}
if len(headers) == 0 {
return "", nil
}
return headers[0].ID, nil
}
// headlessSession is the session state backing one headless run: the store, the
// header (whose ID is the session id emitted and used for resume), and the
// branch-tracking cursor (curLeaf/persisted) so the run's messages append as a
// branch descending from the resumed leaf rather than flattening the tree.
type headlessSession struct {
store *session.Store
header session.SessionHeader
curLeaf string // active leaf id to descend from; "" for a fresh session
// persisted is the number of agentCtx.Messages already on disk before the
// run; persist appends only Messages[persisted:] as a new branch.
persisted int
// model/provider are the model and provider the run actually used, refreshed
// onto the header before persisting so a resumed run does not write back the
// original session's stale values (matching the REPL, repl.go persistTurn).
model string
provider string
}
// openHeadlessSession resolves the session backing a headless run: it resumes an
// existing session when resumeID is set (seeding priorMsgs and re-anchoring the
// branch leaf) or creates a fresh session header otherwise. It returns the prior
// messages to seed into the context ahead of the new prompt, plus the session
// state used to persist the run afterward.
func openHeadlessSession(resumeID, model, providerName, sysPrompt string) (agentcore.MessageList, headlessSession, error) {
store, err := SessionStore()
if err != nil {
return nil, headlessSession{}, err
}
now := time.Now().UTC()
if resumeID != "" {
h, entries, err := store.LoadEntries(resumeID)
if err != nil {
return nil, headlessSession{}, err
}
msgs := make(agentcore.MessageList, len(entries))
for i, e := range entries {
msgs[i] = e.Message
}
curLeaf := ""
if len(entries) > 0 {
curLeaf = entries[len(entries)-1].ID
}
// A resumed header keeps its own SystemPrompt when present so the run is
// faithful to the original session.
if h.SystemPrompt == "" {
h.SystemPrompt = sysPrompt
}
return msgs, headlessSession{store: store, header: h, curLeaf: curLeaf, persisted: len(msgs), model: model, provider: providerName}, nil
}
header := session.SessionHeader{
ID: session.NewID(now),
CreatedAt: now,
UpdatedAt: now,
Model: model,
Provider: providerName,
SystemPrompt: sysPrompt,
Cwd: headlessCwd(),
}
return nil, headlessSession{store: store, header: header, curLeaf: "", persisted: 0, model: model, provider: providerName}, nil
}
// headlessCwd returns the absolute working directory the run executes in, used
// to attribute the session to a project (SessionHeader.Cwd → project id) so a
// later /dream pass can distill this session under the right project scope. An
// unresolvable cwd yields "" (the session stays unattributed) rather than
// aborting the run.
func headlessCwd() string {
wd, err := os.Getwd()
if err != nil {
return ""
}
return wd
}
// persist appends the messages produced during the run — everything in
// agentCtx.Messages past what was already on disk — as a branch descending from
// the resumed leaf, matching how the REPL grows a session tree (AppendBranch).
// It is a no-op when the run produced nothing new. Errors are returned for the
// caller to surface; the run's output has already been emitted regardless.
func (hs *headlessSession) persist(agentCtx *agentcore.AgentContext) error {
// Compaction can rebuild agentCtx.Messages to fewer entries than were on disk
// before the run (loop.go maybeAutoCompact replaces the slice). Clamp the
// cursor so the tail slice stays in bounds; when the context shrank there is
// nothing new to append past what compaction kept.
if hs.persisted > len(agentCtx.Messages) {
hs.persisted = len(agentCtx.Messages)
}
tail := agentCtx.Messages[hs.persisted:]
if len(tail) == 0 {
return nil
}
// Refresh the header with the model/provider the run actually used so a
// resumed session's metadata is not written back stale (matching the REPL).
hs.header.Model = hs.model
hs.header.Provider = hs.provider
hs.header.UpdatedAt = time.Now().UTC()
if _, err := hs.store.AppendBranch(hs.header, hs.curLeaf, tail); err != nil {
return err
}
hs.persisted = len(agentCtx.Messages)
return nil
}