add antigravity print feature and update configs

This commit is contained in:
toki 2026-05-22 07:56:39 +09:00
parent 0679621190
commit 9d8cafe3b7
8 changed files with 347 additions and 6 deletions

View file

@ -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 <prompt>`, `codex exec --dangerously-bypass-approvals-and-sandbox`, `cline -y --json --config /config/.cline/profiles/ollama-dgx <prompt>`처럼 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 <prompt>`를 실행하고, 같은 IOP session의 다음 요청은 `agy --conversation <conversation_id> --print <prompt>` 형태로 이어간다. `codex exec --dangerously-bypass-approvals-and-sandbox`, `cline -y --json --config /config/.cline/profiles/ollama-dgx <prompt>`처럼 다른 headless 조합도 설정할 수 있다. `opencode``mode: "opencode-sse"` profile로 `opencode serve`의 HTTP/SSE 인터페이스를 사용하며, args의 `--model`, `--dangerously-skip-permissions`, `--title` 등은 그대로 유지하면 된다.
실행 순서:

View file

@ -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 <prompt>`로 새 conversation을 만들고 로그에서 conversation id를 읽어 저장한다. 같은 IOP session의 다음 실행은 `resume_args`를 사용해 `agy --conversation <conversation_id> --print <prompt>` 형태로 재개한다.
`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 <url>`을 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 설정으로 변환한 값이다.

View file

@ -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
}

View file

@ -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
}

View file

@ -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]

View file

@ -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)
}

View file

@ -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:

View file

@ -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) {