diff --git a/apps/edge/README.md b/apps/edge/README.md index c1b22fc..9cb13f8 100644 --- a/apps/edge/README.md +++ b/apps/edge/README.md @@ -38,7 +38,7 @@ ops console은 edge-local diagnostic surface이고, HTTP/API는 central/remote m **전제 조건**: `configs/edge.yaml`의 `console.target`이 가리키는 CLI profile command가 node 호스트에서 실행 가능해야 한다. 기본 예시는 `opencode`이며, `claude`, `claude-tui`, `antigravity`, `codex`, `cline-dgx` 등으로 바꿀 수 있다. -같은 `cli` 어댑터 안에 `claude`, `claude-tui`, `antigravity`, `codex`, `opencode`, `cline` profile을 포함할 수 있다. 예를 들어 `claude`는 `claude -p` 기반 one-shot profile로 두고, `claude-tui`는 `persistent: true`, `terminal: true`, `mode: "persistent-lazy"`로 첫 요청 시 일반 Claude Code TUI 세션을 띄운다. `agy --dangerously-skip-permissions --print `, `codex exec --dangerously-bypass-approvals-and-sandbox`, `cline -y --json --config /config/.cline/profiles/ollama-dgx `처럼 headless 조합도 설정할 수 있다. `opencode`는 `mode: "opencode-sse"` profile로 `opencode serve`의 HTTP/SSE 인터페이스를 사용하며, args의 `--model`, `--dangerously-skip-permissions`, `--title` 등은 그대로 유지하면 된다. +같은 `cli` 어댑터 안에 `claude`, `claude-tui`, `antigravity`, `codex`, `opencode`, `cline` profile을 포함할 수 있다. 예를 들어 `claude`는 `claude -p` 기반 one-shot profile로 두고, `claude-tui`는 `persistent: true`, `terminal: true`, `mode: "persistent-lazy"`로 첫 요청 시 일반 Claude Code TUI 세션을 띄운다. `antigravity`는 `mode: "antigravity-print"`로 `agy --dangerously-skip-permissions --print `를 실행하고, 같은 IOP session의 다음 요청은 `agy --conversation --print ` 형태로 이어간다. `codex exec --dangerously-bypass-approvals-and-sandbox`, `cline -y --json --config /config/.cline/profiles/ollama-dgx `처럼 다른 headless 조합도 설정할 수 있다. `opencode`는 `mode: "opencode-sse"` profile로 `opencode serve`의 HTTP/SSE 인터페이스를 사용하며, args의 `--model`, `--dangerously-skip-permissions`, `--title` 등은 그대로 유지하면 된다. 실행 순서: diff --git a/apps/node/README.md b/apps/node/README.md index 6eacfa7..94aa94d 100644 --- a/apps/node/README.md +++ b/apps/node/README.md @@ -152,6 +152,8 @@ Ollama adapter는 edge에서 받은 내부 target을 Ollama model 이름으로 Claude는 용도별로 두 profile을 둔다. `claude`는 `claude -p` 기반 one-shot profile이며 Agent SDK/credit 경로 검증용으로 유지한다. `claude-tui`는 `persistent: true`, `terminal: true`, `mode: "persistent-lazy"`로 일반 Claude Code TUI를 첫 요청 시 lazy start한다. TUI 출력은 structured JSON이 아니므로 idle timeout 기반으로 completion을 판단한다. +Antigravity는 `mode: "antigravity-print"`로 설정해 TUI가 아닌 print mode에서도 IOP `session_id`별 conversation을 이어간다. 첫 실행은 `agy --print `로 새 conversation을 만들고 로그에서 conversation id를 읽어 저장한다. 같은 IOP session의 다음 실행은 `resume_args`를 사용해 `agy --conversation --print ` 형태로 재개한다. + `opencode`는 profile `mode: "opencode-sse"`로 설정해 `opencode serve`의 HTTP/SSE 인터페이스를 사용한다. `profile.Command`가 가리키는 opencode 바이너리를 `serve --hostname 127.0.0.1 --port 0`으로 띄우고 `/global/event` SSE에서 text delta를 `RuntimeEvent` delta로 relay한다. 외부에서 이미 실행 중인 server에 연결하려면 `--attach `을 args에 추가한다. legacy `output_format: "opencode-json"` stdout JSONL 경로는 `opencode run --format json` 사용 시에만 의미가 있다. Cline은 mutable `--config` 디렉터리를 프로필처럼 나눈다. `/config/.cline/profiles/ollama-dgx`와 `/config/.cline/profiles/ollama-m1`는 `opencode`의 `ollama-dgx`/`ollama-m1` provider 설정을 Cline의 `ollama` provider 설정으로 변환한 값이다. diff --git a/apps/node/internal/adapters/cli/antigravity_print.go b/apps/node/internal/adapters/cli/antigravity_print.go new file mode 100644 index 0000000..22aef7b --- /dev/null +++ b/apps/node/internal/adapters/cli/antigravity_print.go @@ -0,0 +1,127 @@ +package cli + +import ( + "context" + "fmt" + "os" + "regexp" + + "iop/apps/node/internal/runtime" + "iop/packages/config" +) + +var antigravityConversationPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?m)Created conversation (` + uuidPattern + `)`), + regexp.MustCompile(`(?m)Streaming conversation (` + uuidPattern + `)`), + regexp.MustCompile(`(?m)Print mode: conversation=(` + uuidPattern + `)`), + regexp.MustCompile(`(?m)conversationID="(` + uuidPattern + `)"`), +} + +const uuidPattern = `[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}` + +func (c *CLI) executeAntigravityPrint(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error { + if len(profile.ResumeArgs) == 0 { + return fmt.Errorf("cli adapter: antigravity-print mode requires resume_args in profile %q", spec.Target) + } + + sess, err := c.resolveAntigravitySession(spec) + if err != nil { + return err + } + + sess.mu.Lock() + defer sess.mu.Unlock() + + logFile, cleanup, err := createAntigravityLogFile() + if err != nil { + return err + } + defer cleanup() + + prompt := extractPrompt(spec.Input) + args := antigravityPrintArgs(profile, sess.conversationID, logFile, prompt) + output, err := c.executeCommand(ctx, spec, profile, args, prompt, sink) + if err != nil { + return err + } + + logOutput, readErr := os.ReadFile(logFile) + if readErr != nil { + return fmt.Errorf("cli adapter: read antigravity log: %w", readErr) + } + if conversationID := parseAntigravityConversationID(string(logOutput) + output); conversationID != "" { + sess.conversationID = conversationID + } + if sess.conversationID == "" { + return fmt.Errorf("cli adapter: antigravity-print did not report a conversation id") + } + return nil +} + +func (c *CLI) resolveAntigravitySession(spec runtime.ExecutionSpec) (*antigravitySession, error) { + target := cliTargetName(spec) + key := sessionKey{target: target, sessionID: normalizeSessionID(spec.SessionID)} + + c.mu.Lock() + defer c.mu.Unlock() + + if sess, ok := c.agySessions[key]; ok { + return sess, nil + } + if spec.SessionMode == runtime.SessionModeRequireExisting { + return nil, fmt.Errorf("cli adapter: no antigravity conversation for target %q session %q", target, key.sessionID) + } + sess := &antigravitySession{key: key} + c.agySessions[key] = sess + return sess, nil +} + +func antigravityPrintArgs(profile config.CLIProfileConf, conversationID, logFile, prompt string) []string { + args := []string{"--log-file", logFile} + if conversationID == "" { + args = append(args, profile.Args...) + } else { + args = append(args, removeAntigravityPrintFlag(profile.ResumeArgs)...) + args = append(args, conversationID) + args = append(args, "--print") + } + return append(args, prompt) +} + +func removeAntigravityPrintFlag(args []string) []string { + filtered := make([]string, 0, len(args)) + for _, arg := range args { + switch arg { + case "--print", "--prompt", "-p": + continue + default: + filtered = append(filtered, arg) + } + } + return filtered +} + +func createAntigravityLogFile() (string, func(), error) { + f, err := os.CreateTemp("", "iop-antigravity-*.log") + if err != nil { + return "", nil, fmt.Errorf("cli adapter: create antigravity log file: %w", err) + } + path := f.Name() + if err := f.Close(); err != nil { + _ = os.Remove(path) + return "", nil, fmt.Errorf("cli adapter: close antigravity log file: %w", err) + } + return path, func() { _ = os.Remove(path) }, nil +} + +func parseAntigravityConversationID(s string) string { + var id string + for _, re := range antigravityConversationPatterns { + for _, match := range re.FindAllStringSubmatch(s, -1) { + if len(match) >= 2 { + id = match[1] + } + } + } + return id +} diff --git a/apps/node/internal/adapters/cli/antigravity_print_blackbox_test.go b/apps/node/internal/adapters/cli/antigravity_print_blackbox_test.go new file mode 100644 index 0000000..fa7a4f1 --- /dev/null +++ b/apps/node/internal/adapters/cli/antigravity_print_blackbox_test.go @@ -0,0 +1,157 @@ +package cli_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "go.uber.org/zap" + + clipkg "iop/apps/node/internal/adapters/cli" + "iop/apps/node/internal/adapters/cli/internal/testutil" + noderuntime "iop/apps/node/internal/runtime" + "iop/packages/config" +) + +func TestCLIExecuteAntigravityPrintResumesLogicalSession(t *testing.T) { + testutil.RequireUnixShell(t) + + agy := writeFakeAgy(t, `#!/usr/bin/env sh +log_file="" +conversation="" +prompt="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --log-file) + log_file="$2" + shift 2 + ;; + --conversation) + conversation="$2" + shift 2 + ;; + --print-timeout) + shift 2 + ;; + --dangerously-skip-permissions|--print) + shift + ;; + *) + prompt="$1" + shift + ;; + esac +done + +if [ -z "$log_file" ]; then + echo "missing --log-file" >&2 + exit 9 +fi + +if [ -z "$conversation" ]; then + printf 'I0000 server.go:747] Created conversation 11111111-2222-3333-4444-555555555555\n' >> "$log_file" + printf 'reply:first:%s\n' "$prompt" +else + printf 'I0000 printmode.go:71] Print mode: starting (promptLength=1, model="", conversationID="%s")\n' "$conversation" >> "$log_file" + printf 'reply:resume:%s:%s\n' "$conversation" "$prompt" +fi +`) + cfg := config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{ + "antigravity": { + Command: agy, + Args: []string{ + "--dangerously-skip-permissions", + "--print-timeout", + "10m", + "--print", + }, + Mode: "antigravity-print", + ResumeArgs: []string{ + "--dangerously-skip-permissions", + "--print-timeout", + "10m", + "--conversation", + }, + }, + }, + } + + c := clipkg.New(cfg, zap.NewNop()) + ctx := context.Background() + + first := &testutil.FakeSink{} + if err := c.Execute(ctx, noderuntime.ExecutionSpec{ + RunID: "run-1", + Target: "antigravity", + SessionID: "session-a", + Input: map[string]any{"prompt": "first"}, + }, first); err != nil { + t.Fatalf("first execute: %v", err) + } + if combined := testutil.CollectDeltas(first.Events()); !strings.Contains(combined, "reply:first:first") { + t.Fatalf("first execute output: %q", combined) + } + + second := &testutil.FakeSink{} + if err := c.Execute(ctx, noderuntime.ExecutionSpec{ + RunID: "run-2", + Target: "antigravity", + SessionID: "session-a", + Input: map[string]any{"prompt": "second"}, + }, second); err != nil { + t.Fatalf("second execute: %v", err) + } + if combined := testutil.CollectDeltas(second.Events()); !strings.Contains(combined, "reply:resume:11111111-2222-3333-4444-555555555555:second") { + t.Fatalf("second execute output: %q", combined) + } +} + +func TestCLIExecuteAntigravityPrint_MissingResumeArgsErrors(t *testing.T) { + testutil.RequireUnixShell(t) + + agy := writeFakeAgy(t, `#!/usr/bin/env sh +echo "should not be called when resume_args is missing" >&2 +exit 42 +`) + cfg := config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{ + "antigravity": { + Command: agy, + Args: []string{"--print"}, + Mode: "antigravity-print", + ResumeArgs: nil, + }, + }, + } + + c := clipkg.New(cfg, zap.NewNop()) + err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-1", + Target: "antigravity", + SessionID: "session-a", + Input: map[string]any{"prompt": "first"}, + }, &testutil.FakeSink{}) + if err == nil { + t.Fatalf("expected error due to missing resume_args, got nil") + } + if !strings.Contains(err.Error(), "resume_args") { + t.Fatalf("expected error to mention resume_args, got: %v", err) + } +} + +func writeFakeAgy(t *testing.T, script string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "agy") + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatalf("write fake agy: %v", err) + } + return path +} diff --git a/apps/node/internal/adapters/cli/cli.go b/apps/node/internal/adapters/cli/cli.go index 4dc1c09..03da330 100644 --- a/apps/node/internal/adapters/cli/cli.go +++ b/apps/node/internal/adapters/cli/cli.go @@ -28,6 +28,7 @@ const Name = "cli" const ( modeCodexExec = "codex-exec" + modeAntigravity = "antigravity-print" modeOpencodeSSE = "opencode-sse" modePersistentLazy = "persistent-lazy" ) @@ -61,11 +62,18 @@ type codexExecSession struct { mu sync.Mutex } +type antigravitySession struct { + key sessionKey + conversationID string + mu sync.Mutex +} + type CLI struct { mu sync.Mutex profiles map[string]config.CLIProfileConf sessions map[sessionKey]*profileSession codexSessions map[sessionKey]*codexExecSession + agySessions map[sessionKey]*antigravitySession opencodeSessions map[sessionKey]*opencodeSSESession logger *zap.Logger @@ -78,6 +86,7 @@ func New(cfg config.CLIConf, logger *zap.Logger) *CLI { profiles: cfg.Profiles, sessions: make(map[sessionKey]*profileSession), codexSessions: make(map[sessionKey]*codexExecSession), + agySessions: make(map[sessionKey]*antigravitySession), opencodeSessions: make(map[sessionKey]*opencodeSSESession), logger: logger, } @@ -131,6 +140,7 @@ func (c *CLI) Start(ctx context.Context) error { func shouldAutostartPersistentProfile(profile config.CLIProfileConf) bool { return profile.Persistent && profile.Mode != modeCodexExec && + profile.Mode != modeAntigravity && profile.Mode != modeOpencodeSSE && profile.Mode != modePersistentLazy } @@ -157,6 +167,7 @@ func (c *CLI) Stop(_ context.Context) error { } c.sessions = make(map[sessionKey]*profileSession) c.codexSessions = make(map[sessionKey]*codexExecSession) + c.agySessions = make(map[sessionKey]*antigravitySession) opencodeCopy := make(map[sessionKey]*opencodeSSESession, len(c.opencodeSessions)) for key, sess := range c.opencodeSessions { opencodeCopy[key] = sess @@ -186,6 +197,9 @@ func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runt if profile.Mode == modeCodexExec { return c.executeCodexExec(ctx, spec, profile, sink) } + if profile.Mode == modeAntigravity { + return c.executeAntigravityPrint(ctx, spec, profile, sink) + } if profile.Mode == modeOpencodeSSE { return c.executeOpencodeSSE(ctx, spec, profile, sink) } @@ -232,13 +246,16 @@ func (c *CLI) handleUsageStatus(ctx context.Context, req runtime.CommandRequest) func (c *CLI) handleSessionList(req runtime.CommandRequest) runtime.CommandResponse { c.mu.Lock() - entries := make([]string, 0, len(c.sessions)+len(c.codexSessions)+len(c.opencodeSessions)) + entries := make([]string, 0, len(c.sessions)+len(c.codexSessions)+len(c.agySessions)+len(c.opencodeSessions)) for k := range c.sessions { entries = append(entries, fmt.Sprintf("persistent:%s/%s", k.target, k.sessionID)) } for k := range c.codexSessions { entries = append(entries, fmt.Sprintf("codex-exec:%s/%s", k.target, k.sessionID)) } + for k := range c.agySessions { + entries = append(entries, fmt.Sprintf("antigravity-print:%s/%s", k.target, k.sessionID)) + } for k := range c.opencodeSessions { entries = append(entries, fmt.Sprintf("opencode-sse:%s/%s", k.target, k.sessionID)) } @@ -273,6 +290,18 @@ func (c *CLI) TerminateSession(_ context.Context, target, sessionID string) erro } return nil } + if profile, ok := c.profiles[target]; ok && profile.Mode == modeAntigravity { + c.mu.Lock() + _, ok := c.agySessions[key] + if ok { + delete(c.agySessions, key) + } + c.mu.Unlock() + if !ok { + return fmt.Errorf("cli adapter: no session %q for target %q", key.sessionID, target) + } + return nil + } if profile, ok := c.profiles[target]; ok && profile.Mode == modeOpencodeSSE { c.mu.Lock() sess, ok := c.opencodeSessions[key] diff --git a/apps/node/internal/adapters/cli/cli_internal_test.go b/apps/node/internal/adapters/cli/cli_internal_test.go index 67267d7..8f0f96f 100644 --- a/apps/node/internal/adapters/cli/cli_internal_test.go +++ b/apps/node/internal/adapters/cli/cli_internal_test.go @@ -367,11 +367,13 @@ func TestHandleSessionList_PopulatedSnapshot(t *testing.T) { c := &CLI{ sessions: make(map[sessionKey]*profileSession), codexSessions: make(map[sessionKey]*codexExecSession), + agySessions: make(map[sessionKey]*antigravitySession), opencodeSessions: make(map[sessionKey]*opencodeSSESession), } c.sessions[sessionKey{target: "claude", sessionID: "default"}] = &profileSession{} c.sessions[sessionKey{target: "claude", sessionID: "alt"}] = &profileSession{} c.codexSessions[sessionKey{target: "codex", sessionID: "default"}] = &codexExecSession{} + c.agySessions[sessionKey{target: "antigravity", sessionID: "main"}] = &antigravitySession{} c.opencodeSessions[sessionKey{target: "opencode", sessionID: "main"}] = &opencodeSSESession{} resp := c.handleSessionList(runtime.CommandRequest{ @@ -380,10 +382,10 @@ func TestHandleSessionList_PopulatedSnapshot(t *testing.T) { Adapter: "cli", }) - if resp.Result["count"] != "4" { - t.Fatalf("count: got %q want %q", resp.Result["count"], "4") + if resp.Result["count"] != "5" { + t.Fatalf("count: got %q want %q", resp.Result["count"], "5") } - want := "codex-exec:codex/default,opencode-sse:opencode/main,persistent:claude/alt,persistent:claude/default" + want := "antigravity-print:antigravity/main,codex-exec:codex/default,opencode-sse:opencode/main,persistent:claude/alt,persistent:claude/default" if got := resp.Result["sessions"]; got != want { t.Fatalf("sessions: got %q want %q", got, want) } diff --git a/configs/edge.yaml b/configs/edge.yaml index a8ebfb2..7e9e024 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -38,7 +38,7 @@ openai: console: adapter: "cli" - target: "claude-tui" + target: "antigravity" session_id: "default" background: false timeout_sec: 300 @@ -91,9 +91,15 @@ nodes: - "--print-timeout" - "10m" - "--print" + resume_args: + - "--dangerously-skip-permissions" + - "--print-timeout" + - "10m" + - "--conversation" env: [] persistent: false terminal: false + mode: "antigravity-print" codex: command: "codex" args: diff --git a/packages/config/config_test.go b/packages/config/config_test.go index 9929573..80438c2 100644 --- a/packages/config/config_test.go +++ b/packages/config/config_test.go @@ -324,6 +324,12 @@ nodes: - "--print-timeout" - "10m" - "--print" + resume_args: + - "--dangerously-skip-permissions" + - "--print-timeout" + - "10m" + - "--conversation" + mode: "antigravity-print" ` if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { t.Fatalf("write yaml: %v", err) @@ -356,6 +362,18 @@ nodes: if agy.OutputFormat != "" { t.Errorf("expected empty output format, got %q", agy.OutputFormat) } + if agy.Mode != "antigravity-print" { + t.Errorf("expected mode antigravity-print, got %q", agy.Mode) + } + expectedResume := []string{"--dangerously-skip-permissions", "--print-timeout", "10m", "--conversation"} + if len(agy.ResumeArgs) != len(expectedResume) { + t.Fatalf("expected %d resume_args, got %d: %v", len(expectedResume), len(agy.ResumeArgs), agy.ResumeArgs) + } + for i, v := range expectedResume { + if agy.ResumeArgs[i] != v { + t.Errorf("resume_args[%d]: expected %q, got %q", i, v, agy.ResumeArgs[i]) + } + } } func TestCLIProfileConf_CompletionMarkerUnmarshal(t *testing.T) {