186 lines
6.6 KiB
Go
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
|
|
}
|