원격 터미널 브리지 선행 작업을 위해 PTY session core를 node-owned terminal package로 분리하고, CLI persistent executor가 새 경계를 사용하도록 정리한다. 검증 루프 산출물과 roadmap 컨텍스트도 함께 반영해 완료 근거와 후속 포트 표준화 범위를 남긴다.
455 lines
12 KiB
Go
455 lines
12 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"
|
|
|
|
"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"
|
|
modeAntigravity = "antigravity-print"
|
|
modeOpencodeSSE = "opencode-sse"
|
|
modePersistentLazy = "persistent-lazy"
|
|
)
|
|
|
|
type cliOutput struct {
|
|
text string
|
|
markerLine string
|
|
}
|
|
|
|
// sessionKey uniquely identifies a logical worker session.
|
|
type sessionKey struct {
|
|
target string
|
|
sessionID string
|
|
}
|
|
|
|
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
|
|
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.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.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: 4,
|
|
}, 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 != 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 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 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
|
|
}
|
|
|
|
func (e sessionListEntry) label() string {
|
|
return e.mode + ":" + e.target + "/" + e.sessionID
|
|
}
|
|
|
|
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
|
|
}
|
|
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
|
|
}
|