iop/apps/node/internal/adapters/cli/cli.go

532 lines
14 KiB
Go

// Package cli provides an Adapter that runs external CLI tools as an execution
// adapter. Profiles (claude, antigravity, codex, opencode, cline) are defined by
// edge configuration, pushed to the node in NodeConfigPayload, and selected as
// the adapter execution target. Interactive CLIs should be configured with
// their non-interactive/headless flags for use in the request/response node
// pipeline.
package cli
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"iop/apps/node/internal/adapters/cli/status"
"iop/apps/node/internal/runtime"
"iop/apps/node/internal/terminal"
"iop/packages/go/config"
)
const Name = "cli"
const (
modeCodexExec = "codex-exec"
modeCodexAppServer = "codex-app-server"
modeAntigravity = "antigravity-print"
modeOpencodeSSE = "opencode-sse"
modePersistentLazy = "persistent-lazy"
)
type cliOutput struct {
text string
markerLine string
}
// sessionKey uniquely identifies a logical worker session. workspace is the
// normalized workspace directory bound to the session; an empty workspace keeps
// the historical no-workspace behavior and labels unchanged.
type sessionKey struct {
target string
sessionID string
workspace string
}
// newSessionKey builds a workspace-aware session key from an execution spec.
// An empty/whitespace workspace normalizes to "" so existing no-workspace
// sessions keep the same key as before workspace support.
func newSessionKey(spec runtime.ExecutionSpec) sessionKey {
return sessionKey{
target: cliTargetName(spec),
sessionID: normalizeSessionID(spec.SessionID),
workspace: workspaceDir(spec.Workspace),
}
}
type profileSession struct {
key sessionKey
name string
profile config.CLIProfileConf
cmd *exec.Cmd
input io.Writer
output <-chan cliOutput
done <-chan error
closeFn func() error
mu sync.Mutex
tailMu sync.Mutex
tail strings.Builder
core terminal.Session
}
func (s *profileSession) appendTail(text string) {
s.tailMu.Lock()
defer s.tailMu.Unlock()
appendBounded(&s.tail, text, 2048)
}
func (s *profileSession) getTail() string {
if s.core != nil {
return s.core.Snapshot().Tail
}
s.tailMu.Lock()
defer s.tailMu.Unlock()
return s.tail.String()
}
type codexExecSession struct {
key sessionKey
externalID string
mu sync.Mutex
}
type antigravitySession struct {
key sessionKey
conversationID string
mu sync.Mutex
}
type CLI struct {
mu sync.Mutex
profiles map[string]config.CLIProfileConf
logger *zap.Logger
StatusChecker func(ctx context.Context, target string, profile config.CLIProfileConf) (*status.UsageStatus, error)
oneShotExecutor *oneshotExecutor
persistentExecutor *persistentExecutor
codexExecutor *codexExecutor
codexAppServerExecutor *codexAppServerExecutor
antigravityExecutor *antigravityExecutor
opencodeExecutor *opencodeExecutor
reporters []sessionReporter
}
type executor interface {
Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error
}
type sessionReporter interface {
Sessions() []sessionListEntry
Terminate(ctx context.Context, target, sessionID string) (bool, error)
Stop(ctx context.Context) error
}
type oneshotExecutor struct {
cli *CLI
}
func (e *oneshotExecutor) Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
return e.cli.executeOneShot(ctx, spec, profile, sink)
}
type persistentExecutor struct {
cli *CLI
mu sync.Mutex
sessions map[sessionKey]*profileSession
}
type codexExecutor struct {
cli *CLI
mu sync.Mutex
sessions map[sessionKey]*codexExecSession
}
type antigravityExecutor struct {
cli *CLI
mu sync.Mutex
sessions map[sessionKey]*antigravitySession
}
type opencodeExecutor struct {
cli *CLI
mu sync.Mutex
sessions map[sessionKey]*opencodeSSESession
}
func New(cfg config.CLIConf, logger *zap.Logger) *CLI {
c := &CLI{
profiles: cfg.Profiles,
logger: logger,
}
c.oneShotExecutor = &oneshotExecutor{cli: c}
c.persistentExecutor = &persistentExecutor{
cli: c,
sessions: make(map[sessionKey]*profileSession),
}
c.codexExecutor = &codexExecutor{
cli: c,
sessions: make(map[sessionKey]*codexExecSession),
}
c.codexAppServerExecutor = &codexAppServerExecutor{
cli: c,
sessions: make(map[sessionKey]*codexAppServerSession),
}
c.antigravityExecutor = &antigravityExecutor{
cli: c,
sessions: make(map[sessionKey]*antigravitySession),
}
c.opencodeExecutor = &opencodeExecutor{
cli: c,
sessions: make(map[sessionKey]*opencodeSSESession),
}
c.reporters = []sessionReporter{
c.persistentExecutor,
c.codexExecutor,
c.codexAppServerExecutor,
c.antigravityExecutor,
c.opencodeExecutor,
}
return c
}
func (c *CLI) Name() string { return Name }
func (c *CLI) Capabilities(_ context.Context) (runtime.Capabilities, error) {
profiles := make([]string, 0, len(c.profiles))
for name := range c.profiles {
profiles = append(profiles, name)
}
sort.Strings(profiles)
return runtime.Capabilities{
AdapterName: Name,
Targets: profiles,
MaxConcurrency: 0,
}, nil
}
// Start starts the default session for each persistent profile in deterministic (sorted) order.
// On failure, already-started sessions are rolled back.
func (c *CLI) Start(ctx context.Context) error {
names := make([]string, 0, len(c.profiles))
for name := range c.profiles {
if shouldAutostartPersistentProfile(c.profiles[name]) {
names = append(names, name)
}
}
sort.Strings(names)
for _, name := range names {
profile := c.profiles[name]
key := sessionKey{target: name, sessionID: runtime.DefaultSessionID}
sess, err := startProfileSession(ctx, key, profile, "", c.logger)
if err != nil {
c.persistentExecutor.mu.Lock()
_ = c.persistentExecutor.stopAllSessions(context.Background())
c.persistentExecutor.mu.Unlock()
return fmt.Errorf("cli adapter: start target %q: %w", name, err)
}
c.persistentExecutor.mu.Lock()
c.persistentExecutor.sessions[key] = sess
c.persistentExecutor.mu.Unlock()
c.logger.Info("cli adapter: persistent session started", zap.String("target", name))
}
return nil
}
func shouldAutostartPersistentProfile(profile config.CLIProfileConf) bool {
return profile.Persistent &&
profile.Mode != modeCodexExec &&
profile.Mode != modeCodexAppServer &&
profile.Mode != modeAntigravity &&
profile.Mode != modeOpencodeSSE &&
profile.Mode != modePersistentLazy
}
// Stop stops all logical sessions. Errors are combined by reporting only the first.
func (c *CLI) Stop(ctx context.Context) error {
var firstErr error
for _, reporter := range c.reporters {
if err := reporter.Stop(ctx); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (c *CLI) executorFor(profile config.CLIProfileConf) executor {
switch profile.Mode {
case modeCodexExec:
return c.codexExecutor
case modeCodexAppServer:
return c.codexAppServerExecutor
case modeAntigravity:
return c.antigravityExecutor
case modeOpencodeSSE:
return c.opencodeExecutor
default:
if profile.Persistent {
return c.persistentExecutor
}
return c.oneShotExecutor
}
}
func (c *CLI) sessionReporterFor(profile config.CLIProfileConf) sessionReporter {
switch profile.Mode {
case modeCodexExec:
return c.codexExecutor
case modeCodexAppServer:
return c.codexAppServerExecutor
case modeAntigravity:
return c.antigravityExecutor
case modeOpencodeSSE:
return c.opencodeExecutor
default:
if profile.Persistent {
return c.persistentExecutor
}
return nil
}
}
func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
target := cliTargetName(spec)
profile, ok := c.profiles[target]
if !ok {
return fmt.Errorf("cli adapter: unknown target %q", target)
}
return c.executorFor(profile).Execute(ctx, spec, profile, sink)
}
func (c *CLI) HandleCommand(ctx context.Context, req runtime.CommandRequest) (runtime.CommandResponse, error) {
switch req.Type {
case runtime.CommandTypeUsageStatus:
return c.handleUsageStatus(ctx, req)
case runtime.CommandTypeSessionList:
return c.handleSessionList(req), nil
default:
return runtime.CommandResponse{}, fmt.Errorf("cli adapter: unsupported command %q", req.Type)
}
}
func (c *CLI) handleUsageStatus(ctx context.Context, req runtime.CommandRequest) (runtime.CommandResponse, error) {
profile, ok := c.profiles[req.Target]
if !ok {
return runtime.CommandResponse{}, fmt.Errorf("cli adapter: unknown target %q", req.Target)
}
checkFn := c.StatusChecker
if checkFn == nil {
checkFn = status.CheckUsage
}
st, err := checkFn(ctx, req.Target, profile)
if err != nil {
return runtime.CommandResponse{}, err
}
if st == nil {
return runtime.CommandResponse{}, fmt.Errorf("cli adapter: status checker returned nil result for target %q", req.Target)
}
runtimeStatus := st.ToRuntime()
if runtimeStatus.Metadata == nil {
runtimeStatus.Metadata = make(map[string]string)
}
annotateUsageParseStatus(runtimeStatus)
return runtime.CommandResponse{
RequestID: req.RequestID,
Type: req.Type,
Adapter: req.Adapter,
Target: req.Target,
SessionID: req.SessionID,
UsageStatus: runtimeStatus,
}, nil
}
// annotateUsageParseStatus sets parse_status metadata only when no structured data is present.
// Structured data means any of: daily/weekly limit fields set, or pre-existing metadata entries.
// - raw-only: RawOutput present but no structured data
// - empty: RawOutput absent and no structured data
// - (no annotation) if structured data exists (parsed fields or metadata-only result)
func annotateUsageParseStatus(s *runtime.AgentUsageStatus) {
hasFields := s.DailyLimit != "" || s.DailyResetTime != "" ||
s.WeeklyLimit != "" || s.WeeklyResetTime != ""
if hasFields || len(s.Metadata) > 0 {
return
}
if s.RawOutput == "" {
s.Metadata["parse_status"] = "empty"
} else {
s.Metadata["parse_status"] = "raw_only"
}
}
// sessionListEntry holds a typed snapshot of a single logical session for SESSION_LIST.
type sessionListEntry struct {
mode string
target string
sessionID string
workspace string
}
// label returns a stable, human-readable identifier for the session. For
// no-workspace sessions the format is unchanged ("mode:target/sessionID");
// when a workspace is bound it is appended ("...#workspace") so workspace
// variants of the same target/sessionID do not collide.
func (e sessionListEntry) label() string {
base := e.mode + ":" + e.target + "/" + e.sessionID
if e.workspace == "" {
return base
}
return base + "#" + e.workspace
}
func (c *CLI) handleSessionList(req runtime.CommandRequest) runtime.CommandResponse {
var snaps []sessionListEntry
for _, r := range c.reporters {
snaps = append(snaps, r.Sessions()...)
}
sort.Slice(snaps, func(i, j int) bool { return snaps[i].label() < snaps[j].label() })
labels := make([]string, len(snaps))
for i, s := range snaps {
labels[i] = s.label()
}
result := map[string]string{
"count": strconv.Itoa(len(snaps)),
"sessions": strings.Join(labels, ","),
}
for i, s := range snaps {
prefix := fmt.Sprintf("session.%d.", i)
result[prefix+"label"] = s.label()
result[prefix+"mode"] = s.mode
result[prefix+"target"] = s.target
result[prefix+"session_id"] = s.sessionID
result[prefix+"workspace"] = s.workspace
}
return runtime.CommandResponse{
RequestID: req.RequestID,
Type: req.Type,
Adapter: req.Adapter,
Target: req.Target,
SessionID: req.SessionID,
Result: result,
}
}
// TerminateSession implements runtime.SessionTerminator.
func (c *CLI) TerminateSession(ctx context.Context, target, sessionID string) error {
var reporter sessionReporter
if profile, ok := c.profiles[target]; ok {
reporter = c.sessionReporterFor(profile)
}
if reporter == nil {
reporter = c.persistentExecutor
}
terminated, err := reporter.Terminate(ctx, target, sessionID)
if err != nil {
return err
}
if !terminated {
return fmt.Errorf("cli adapter: no session %q for target %q", normalizeSessionID(sessionID), target)
}
return nil
}
func cliTargetName(spec runtime.ExecutionSpec) string {
return spec.Target
}
func cancelEventForContext(err error) string {
switch {
case errors.Is(err, context.DeadlineExceeded):
return "timeout"
case errors.Is(err, context.Canceled):
return "user-cancel"
default:
return "context-done"
}
}
func normalizeSessionID(id string) string {
if id == "" {
return runtime.DefaultSessionID
}
return id
}
func isAlreadyClosedError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, os.ErrClosed) {
return true
}
errStr := err.Error()
return strings.Contains(errStr, "file already closed") ||
strings.Contains(errStr, "use of closed file")
}
func closeProfileSession(_ context.Context, sess *profileSession) error {
if sess.core != nil {
return sess.core.Close()
}
var err error
if sess.closeFn != nil {
err = sess.closeFn()
}
if sess.cmd != nil && sess.cmd.Process != nil {
_ = sess.cmd.Process.Kill()
}
if isAlreadyClosedError(err) {
return nil
}
return err
}
func workspaceDir(w string) string {
return strings.TrimSpace(w)
}
func prepareWorkspaceDir(w string) (string, error) {
dir := workspaceDir(w)
if dir == "" {
return "", nil
}
info, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("cli adapter: workspace not found: %s", dir)
}
return "", fmt.Errorf("cli adapter: workspace inaccessible: %s: %w", dir, err)
}
if !info.IsDir() {
return "", fmt.Errorf("cli adapter: workspace is not a directory: %s", dir)
}
if f, err := os.Open(dir); err != nil {
return "", fmt.Errorf("cli adapter: workspace inaccessible: %s: %w", dir, err)
} else {
_ = f.Close()
}
return dir, nil
}
func emitReturnedError(ctx context.Context, sink runtime.EventSink, runID string, err error) error {
if err == nil {
return nil
}
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: runID,
Type: runtime.EventTypeError,
Error: err.Error(),
Timestamp: time.Now(),
})
return err
}