작업이 # Claude CLI 터미널 원격 제어 1-cycle ## 이 파일을 읽는 구현 에이전트에게 각 항목의 체크리스트를 완료하면 즉시 체크 표시한다. 중간/최종 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여 넣는다. 계획과 다르게 구현해야 하는 부분이 생기면 이유를 `CODE_REVIEW.md`의 `계획 대비 변경 사항`에 남기고, 모든 구현 항목의 실제 동작과 수동 검증 결과를 기록한다. ## 배경 현재 edge 콘솔과 node CLI는 TCP/protobuf로 `RunRequest`를 보내고 `RunEvent`를 받는 한 사이클을 이미 갖추고 있다. 하지만 `cli` adapter는 요청마다 `exec.CommandContext`를 새로 실행하고 prompt를 argv 끝에 붙이는 구조라, node 시작 직후 Claude 터미널 프로세스를 띄운 뒤 edge 입력을 stdin으로 전달하는 목표와 맞지 않는다. 이번 작업은 기존 hexagonal 경계를 유지하면서 `cli/claude` 프로필 하나로 원격 입력 -> node 터미널 프로세스 -> edge 출력까지 수동 검증 가능한 1-cycle을 완성한다. ## 의존 관계 및 구현 순서 1. [API-1]에서 설정/edge payload/node payload 파싱 계약을 먼저 확장한다. 2. [API-2]에서 확장된 CLI profile 값을 사용하는 persistent terminal process를 구현한다. 3. [API-3]에서 node bootstrap이 persistent adapter lifecycle을 시작/정리하도록 연결한다. 4. [API-4]에서 edge 콘솔 기본값과 문서를 Claude CLI 수동 테스트 흐름에 맞춘다. --- ### [API-1] CLI profile 설정 계약에 persistent terminal 옵션 추가 #### 문제 `packages/config/config.go:95-99`: ```go type CLIProfileConf struct { Command string `mapstructure:"command" yaml:"command"` Args []string `mapstructure:"args" yaml:"args"` Env []string `mapstructure:"env" yaml:"env"` } ``` CLI profile에는 command/args/env만 있어 프로세스를 node 시작 시 띄울지, 터미널(PTY)로 실행할지, 응답을 어떤 기준으로 한 RunEvent cycle로 자를지 표현할 수 없다. `apps/edge/internal/transport/server.go:190-199`: ```go if rec.Adapters.CLI.Enabled { profiles := make(map[string]any) for name, p := range rec.Adapters.CLI.Profiles { profiles[name] = map[string]any{ "command": p.Command, "args": stringsToAny(p.Args), "env": stringsToAny(p.Env), } } if err := addAdapter("cli", true, map[string]any{"profiles": profiles}); err != nil { ``` Edge가 node에 내려주는 CLI payload도 동일하게 command/args/env만 전달한다. `apps/node/internal/adapters/factory.go:81-99`: ```go prof := config.CLIProfileConf{} if cmd, ok := p["command"].(string); ok { prof.Command = cmd } if args, ok := p["args"].([]any); ok { for _, a := range args { if s, ok := a.(string); ok { prof.Args = append(prof.Args, s) } } } if envs, ok := p["env"].([]any); ok { for _, e := range envs { if s, ok := e.(string); ok { prof.Env = append(prof.Env, s) ``` Node payload parser도 새 profile 동작을 받을 수 없다. #### 해결 방법 `config.CLIProfileConf`에 아래 필드를 추가한다. `persistent=false`가 기본값이므로 기존 one-shot CLI adapter 동작은 유지된다. ```go type CLIProfileConf struct { Command string `mapstructure:"command" yaml:"command"` Args []string `mapstructure:"args" yaml:"args"` Env []string `mapstructure:"env" yaml:"env"` Persistent bool `mapstructure:"persistent" yaml:"persistent"` Terminal bool `mapstructure:"terminal" yaml:"terminal"` ResponseIdleTimeoutMS int `mapstructure:"response_idle_timeout_ms" yaml:"response_idle_timeout_ms"` StartupIdleTimeoutMS int `mapstructure:"startup_idle_timeout_ms" yaml:"startup_idle_timeout_ms"` } ``` `buildConfigPayload`는 CLI profile map에 새 필드를 그대로 포함한다. ```go profiles[name] = map[string]any{ "command": p.Command, "args": stringsToAny(p.Args), "env": stringsToAny(p.Env), "persistent": p.Persistent, "terminal": p.Terminal, "response_idle_timeout_ms": p.ResponseIdleTimeoutMS, "startup_idle_timeout_ms": p.StartupIdleTimeoutMS, } ``` `cliConfFromStruct`는 `structpb.Struct.AsMap()`의 number가 `float64`로 들어오는 점을 고려해 bool/int 변환 helper를 둔다. ```go if persistent, ok := boolFromAny(p["persistent"]); ok { prof.Persistent = persistent } if terminal, ok := boolFromAny(p["terminal"]); ok { prof.Terminal = terminal } if ms, ok := intFromAny(p["response_idle_timeout_ms"]); ok { prof.ResponseIdleTimeoutMS = ms } if ms, ok := intFromAny(p["startup_idle_timeout_ms"]); ok { prof.StartupIdleTimeoutMS = ms } ``` `configs/edge.yaml:14-34`는 원격 CLI 테스트용 기본값으로 전환한다. ```yaml console: adapter: "cli" model: "claude" nodes: - alias: "local-node" token: "changeme" adapters: cli: enabled: true profiles: claude: command: "claude" args: [] env: [] persistent: true terminal: true response_idle_timeout_ms: 1500 startup_idle_timeout_ms: 300 ``` 이번 항목은 rename/removal이 없으므로 호출부 갱신 대상은 없다. 값 추가에 따른 변경 call site는 `apps/edge/internal/transport/server.go:190-199`, `apps/node/internal/adapters/factory.go:81-99`, `configs/edge.yaml:14-34`이다. #### 수정 파일 및 체크리스트 - [ ] `packages/config/config.go` - `CLIProfileConf` 새 필드 추가 - [ ] `apps/edge/internal/transport/server.go` - `buildConfigPayload` CLI profile payload에 persistent/terminal/timeout 필드 추가 - [ ] `apps/edge/internal/transport/server_test.go` - CLI payload roundtrip 테스트에 새 필드 assertion 추가 - [ ] `apps/node/internal/adapters/factory.go` - `cliConfFromStruct`에서 bool/int 필드 파싱 추가 - [ ] `apps/node/internal/adapters/factory_internal_test.go` - `cliConfFromStruct` 내부 테스트 추가 - [ ] `configs/edge.yaml` - console target과 local-node CLI profile을 `cli/claude` persistent terminal로 변경 #### 테스트 작성 작성: - `apps/edge/internal/transport/server_test.go`의 `TestBuildConfigPayload_CLIArgsRoundtrip`를 확장해 `persistent`, `terminal`, `response_idle_timeout_ms`, `startup_idle_timeout_ms`가 `AdapterConfig.Settings`에 보존되는지 검증한다. - `apps/node/internal/adapters/factory_internal_test.go`를 `package adapters`로 추가하고 `TestCLIConfFromStruct_ProfileRuntimeOptions`를 작성한다. `structpb.NewStruct`로 만든 CLI settings에서 bool/int 값이 `config.CLIProfileConf`로 정상 파싱되는지 확인한다. #### 중간 검증 ```bash go test ./apps/edge/internal/transport ./apps/node/internal/adapters ``` 기대 결과: edge payload roundtrip과 node CLI config parsing 테스트가 통과한다. --- ### [API-2] CLI adapter에 node-start persistent terminal process 구현 #### 문제 `apps/node/internal/adapters/cli/cli.go:57-65`: ```go func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error { profile, ok := c.profiles[spec.Model] if !ok { return fmt.Errorf("cli adapter: unknown profile %q", spec.Model) } prompt := extractPrompt(spec.Input) args := append(append([]string{}, profile.Args...), prompt) cmd := exec.CommandContext(ctx, profile.Command, args...) ``` 현재 구현은 edge 입력을 CLI 프로세스의 stdin으로 전달하지 않고 argv 끝에 prompt를 추가한다. `apps/node/internal/adapters/cli/cli.go:72-112`: ```go stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("cli adapter: stdout pipe: %w", err) } stderr, err := cmd.StderrPipe() if err != nil { return fmt.Errorf("cli adapter: stderr pipe: %w", err) } if err := cmd.Start(); err != nil { return fmt.Errorf("cli adapter: start %q: %w", profile.Command, err) } ... if err := cmd.Wait(); err != nil { ``` 요청마다 새 프로세스를 만들고 프로세스 종료를 기다린다. Claude 같은 interactive terminal CLI를 node 시작 시 한 번 띄우고 여러 edge 입력에 재사용하는 구조가 아니다. `apps/node/internal/adapters/cli/cli.go:122-131`: ```go return sink.Emit(ctx, runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeComplete, Message: "cli execution complete", ``` complete 이벤트가 프로세스 종료에 묶여 있어 persistent process에서는 응답 경계를 만들 수 없다. #### 해결 방법 `github.com/creack/pty`를 사용해 `terminal=true` profile을 PTY로 실행한다. `terminal=false`인 persistent profile은 stdin/stdout pipe를 사용해 테스트 가능성을 확보한다. 새 import는 아래 형태를 기준으로 한다. ```go import ( "bufio" "context" "errors" "fmt" "io" "os" "os/exec" "strings" "sync" "time" "github.com/creack/pty" "go.uber.org/zap" "iop/apps/node/internal/runtime" "iop/packages/config" ) ``` `CLI`에 persistent session map을 둔다. ```go type CLI struct { profiles map[string]config.CLIProfileConf sessions map[string]*profileSession logger *zap.Logger } type profileSession struct { name string profile config.CLIProfileConf cmd *exec.Cmd input io.Writer output <-chan cliOutput done <-chan error closeFn func() error mu sync.Mutex } ``` `Start(ctx)`는 `Persistent=true` profile을 node bootstrap 시점에 시작한다. `Terminal=true`면 `pty.Start(cmd)`로 PTY를 열고, 아니면 `cmd.StdinPipe`/`cmd.StdoutPipe`를 사용한다. startup banner는 `StartupIdleTimeoutMS` 동안 drain해서 첫 RunRequest 응답에 섞이지 않도록 한다. command가 없거나 start 실패 시 error를 반환해 node 시작을 실패시키고 설정 오류를 빨리 드러낸다. ```go func (c *CLI) Start(ctx context.Context) error { for name, profile := range c.profiles { if !profile.Persistent { continue } sess, err := startProfileSession(ctx, name, profile, c.logger) if err != nil { return fmt.Errorf("cli adapter: start profile %q: %w", name, err) } c.sessions[name] = sess } return nil } ``` `Execute`는 `Persistent=false`면 기존 one-shot 로직을 `executeOneShot`로 유지하고, `Persistent=true`면 profile session을 찾아 mutex로 한 요청씩 직렬화한다. prompt는 `input`에 `prompt + "\n"`으로 write하고, output reader goroutine이 보낸 chunk를 `delta`로 emit한다. 응답 경계는 첫 output 이후 `ResponseIdleTimeoutMS` 동안 새 output이 없으면 complete로 처리한다. ```go func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error { profile, ok := c.profiles[spec.Model] if !ok { return fmt.Errorf("cli adapter: unknown profile %q", spec.Model) } if profile.Persistent { return c.executePersistent(ctx, spec, profile, sink) } return c.executeOneShot(ctx, spec, profile, sink) } ``` `Stop(ctx)`는 모든 session에 대해 closeFn을 실행하고 프로세스가 남아 있으면 kill한다. context 취소, 프로세스 종료, reader error는 `RuntimeEvent{Type: error}`로 edge에 전달한다. #### 수정 파일 및 체크리스트 - [ ] `go.mod` / `go.sum` - `go get github.com/creack/pty` 후 `go mod tidy` - [ ] `apps/node/internal/adapters/cli/cli.go` - one-shot 실행을 `executeOneShot`로 분리 - [ ] `apps/node/internal/adapters/cli/cli.go` - persistent profile session, PTY/stdin pipe 시작, output reader, startup drain 구현 - [ ] `apps/node/internal/adapters/cli/cli.go` - persistent `Execute`에서 edge prompt를 stdin/PTY로 write하고 idle timeout 기준으로 delta/complete emit - [ ] `apps/node/internal/adapters/cli/cli.go` - `Start(ctx)`/`Stop(ctx)` lifecycle method 구현 - [ ] `apps/node/internal/adapters/cli/cli_test.go` - one-shot 회귀 테스트와 persistent terminal echo 테스트 추가 #### 테스트 작성 작성: - `apps/node/internal/adapters/cli/cli_test.go` - `TestCLIExecuteOneShotPassesPromptAsArg`: `sh -c 'printf "reply:%s\n" "$1"' sh` 또는 동등한 작은 명령으로 기존 one-shot prompt argv 동작이 유지되는지 검증한다. - `apps/node/internal/adapters/cli/cli_test.go` - `TestCLIStartPersistentTerminalAndExecuteWritesPrompt`: `sh -c 'stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$line"; done'`를 `Persistent=true`, `Terminal=true` profile로 시작하고 `Execute`가 prompt를 write해 `reply:` delta를 받는지 검증한다. 테스트는 Unix shell/PTY가 필요한 경우 `runtime.GOOS == "windows"`에서 skip한다. - fake sink는 emitted events를 slice에 모아 `start`, `delta`, `complete` 순서와 delta 내용 포함 여부를 확인한다. #### 중간 검증 ```bash go test ./apps/node/internal/adapters/cli ``` 기대 결과: one-shot CLI 실행과 persistent terminal 실행 테스트가 모두 통과한다. --- ### [API-3] Node bootstrap에서 adapter lifecycle 시작/정리 연결 #### 문제 `apps/node/internal/bootstrap/module.go:43-63`: ```go reg, err := adapters.BuildFromPayload(result.Config, logger) if err != nil { _ = result.Session.Close() return fmt.Errorf("bootstrap: build adapters: %w", err) } ... rtr := router.New(reg, logger) n := node.New(result.NodeID, rtr, reg, st, logger) result.Session.SetHandler(n) sess = result.Session ``` Node는 edge 등록 후 adapter registry를 만들지만 adapter lifecycle을 시작하지 않는다. 따라서 CLI adapter에 `Start(ctx)`를 추가해도 node 시작 직후 Claude 프로세스가 실행되지 않는다. `apps/node/internal/adapters/registry.go:40-47`: ```go // All returns all registered adapters in registration order. func (r *Registry) All() []runtime.Adapter { out := make([]runtime.Adapter, 0, len(r.order)) for _, name := range r.order { out = append(out, r.adapters[name]) } return out } ``` Registry는 adapter 열거만 제공하고 lifecycle adapter를 시작/정리하는 공통 진입점이 없다. #### 해결 방법 `apps/node/internal/adapters/registry.go`에 optional lifecycle interface와 registry method를 추가한다. CLI package는 이 interface를 import하지 않아도 method set으로 만족한다. ```go type LifecycleAdapter interface { Start(ctx context.Context) error Stop(ctx context.Context) error } func (r *Registry) Start(ctx context.Context) error { for _, adapter := range r.All() { lifecycle, ok := adapter.(LifecycleAdapter) if !ok { continue } if err := lifecycle.Start(ctx); err != nil { return fmt.Errorf("adapter %q start: %w", adapter.Name(), err) } } return nil } func (r *Registry) Stop(ctx context.Context) error { for i := len(r.order) - 1; i >= 0; i-- { adapter := r.adapters[r.order[i]] lifecycle, ok := adapter.(LifecycleAdapter) if !ok { continue } if err := lifecycle.Stop(ctx); err != nil { return fmt.Errorf("adapter %q stop: %w", adapter.Name(), err) } } return nil } ``` 필요한 import 추가: ```go import ( "context" "fmt" "iop/apps/node/internal/runtime" ) ``` `apps/node/internal/bootstrap/module.go`에서는 `reg`를 hook 바깥 변수로 보관하고, store 생성 후 handler attach 전에 `reg.Start(ctx)`를 호출한다. 시작 실패 시 session/store를 닫고 error를 반환한다. `OnStop`에서는 session/store 정리 전에 `reg.Stop(context.Background())`를 호출한다. ```go var sess *transport.Session var st *store.Store var reg *adapters.Registry ... reg, err = adapters.BuildFromPayload(result.Config, logger) ... if err := reg.Start(ctx); err != nil { _ = result.Session.Close() _ = st.Close() return fmt.Errorf("bootstrap: start adapters: %w", err) } ... if reg != nil { if err := reg.Stop(context.Background()); err != nil { return err } } ``` #### 수정 파일 및 체크리스트 - [ ] `apps/node/internal/adapters/registry.go` - optional lifecycle interface와 `Start`/`Stop` methods 추가 - [ ] `apps/node/internal/adapters/registry_test.go` - lifecycle adapter start/stop 호출 테스트 추가 - [ ] `apps/node/internal/bootstrap/module.go` - registry lifecycle을 node startup/shutdown hook에 연결 #### 테스트 작성 작성: - `apps/node/internal/adapters/registry_test.go` - fake adapter가 `Start`/`Stop` method를 구현하게 하고 registry `Start(ctx)`/`Stop(ctx)`가 lifecycle adapter만 호출하며 stop이 등록 역순으로 호출되는지 검증한다. `bootstrap.Module` 자체는 현재 fx integration test가 없으므로 새 테스트를 추가하지 않는다. 대신 `go test ./apps/node/...`로 compile coverage를 확보하고, 최종 수동 검증에서 node 시작 직후 `claude` process start 로그/동작을 확인한다. #### 중간 검증 ```bash go test ./apps/node/internal/adapters ./apps/node/... ``` 기대 결과: registry lifecycle 테스트와 node 전체 package 테스트가 통과한다. --- ### [API-4] Edge 콘솔 수동 테스트를 `cli/claude` 1-cycle에 맞게 정리 #### 문제 `apps/edge/cmd/edge/console.go:111-120`: ```go req := &iop.RunRequest{ RunId: runID, Adapter: adapter, Model: model, Input: input, TimeoutSec: 30, Metadata: map[string]string{ ``` Edge 콘솔의 `RunRequest.TimeoutSec`가 30초로 고정되어 있어 Claude CLI 응답이 느린 환경에서 수동 테스트가 불안정할 수 있다. `apps/edge/cmd/edge/console.go:128-137`: ```go timer := time.NewTimer(35 * time.Second) defer timer.Stop() ... case <-timer.C: return fmt.Errorf("timed out waiting for node response") ``` Edge-side wait timeout도 고정값이다. `apps/edge/README.md:11-35`와 `apps/node/README.md:46-52`는 수동 테스트가 mock adapter 기준이라고 설명한다. `configs/edge.yaml:14-34`도 console target이 `mock/mock-echo`이고 CLI adapter가 disabled라, 현재 문서와 config만으로는 Claude CLI 1-cycle을 실행할 수 없다. #### 해결 방법 `EdgeConsoleConf`에 timeout 설정을 추가하고 기본값을 120초로 둔다. ```go type EdgeConsoleConf struct { Adapter string `mapstructure:"adapter" yaml:"adapter"` Model string `mapstructure:"model" yaml:"model"` TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"` } ``` `setEdgeDefaults`에 기본값을 추가한다. ```go v.SetDefault("console.timeout_sec", 120) ``` `sendConsoleRun` 호출부와 시그니처를 바꾸어 config timeout을 사용한다. Before (`apps/edge/cmd/edge/console.go:81`): ```go if err := sendConsoleRun(ctx, registry, events, out, cfg.Console.Adapter, cfg.Console.Model, message); err != nil { ``` After: ```go if err := sendConsoleRun(ctx, registry, events, out, cfg.Console.Adapter, cfg.Console.Model, cfg.Console.TimeoutSec, message); err != nil { ``` `sendConsoleRun` 내부는 0 이하 값을 30초 fallback으로 보정하고 `RunRequest.TimeoutSec`와 edge wait timer에 함께 적용한다. ```go if timeoutSec <= 0 { timeoutSec = 30 } ... TimeoutSec: int32(timeoutSec), ... timer := time.NewTimer(time.Duration(timeoutSec+5) * time.Second) ``` `apps/edge/README.md`와 `apps/node/README.md`는 `bin/edge.sh`/`bin/node.sh`가 `cli/claude` persistent terminal profile을 사용하는 수동 검증이라는 점, node host에 `claude` 명령이 PATH에 있어야 한다는 점, `edge>` 입력이 node의 Claude process stdin으로 전달된다는 점을 반영한다. #### 수정 파일 및 체크리스트 - [ ] `packages/config/config.go` - `EdgeConsoleConf.TimeoutSec` 필드와 기본값 추가 - [ ] `packages/config/config_test.go` - edge console timeout default/load 테스트 추가 - [ ] `apps/edge/cmd/edge/console.go` - console timeout을 `RunRequest.TimeoutSec`와 response timer에 사용 - [ ] `configs/edge.yaml` - `console.timeout_sec` 값 추가 - [ ] `apps/edge/README.md` - 원격 Claude CLI 수동 테스트 절차와 예상 출력 갱신 - [ ] `apps/node/README.md` - node 측 Claude CLI persistent process 동작 설명 갱신 #### 테스트 작성 작성: - `packages/config/config_test.go` - 최소 edge YAML을 임시 파일로 만들고 `LoadEdge`가 `console.timeout_sec` 기본값을 채우는지 확인한다. 별도 YAML에 `timeout_sec: 45`를 넣어 override가 반영되는지도 확인한다. 스킵: - `apps/edge/cmd/edge/console.go`의 실제 console network loop 테스트는 현재 `edgenode.NodeEntry.Client`가 concrete `*toki.TcpClient`라 fake 주입이 어렵다. 이번 항목에서는 config 테스트와 package compile, 그리고 최종 수동 검증으로 커버한다. #### 중간 검증 ```bash go test ./packages/config ./apps/edge/cmd/edge ``` 기대 결과: console timeout config 테스트와 edge CLI package compile이 통과한다. ## 수정 파일 요약 | 파일 경로 | 작업 항목 | |-----------|-----------| | `go.mod` | [API-2] | | `go.sum` | [API-2] | | `packages/config/config.go` | [API-1], [API-4] | | `packages/config/config_test.go` | [API-4] | | `configs/edge.yaml` | [API-1], [API-4] | | `apps/edge/internal/transport/server.go` | [API-1] | | `apps/edge/internal/transport/server_test.go` | [API-1] | | `apps/edge/cmd/edge/console.go` | [API-4] | | `apps/edge/README.md` | [API-4] | | `apps/node/README.md` | [API-4] | | `apps/node/internal/adapters/factory.go` | [API-1] | | `apps/node/internal/adapters/factory_internal_test.go` | [API-1] | | `apps/node/internal/adapters/registry.go` | [API-3] | | `apps/node/internal/adapters/registry_test.go` | [API-3] | | `apps/node/internal/adapters/cli/cli.go` | [API-2] | | `apps/node/internal/adapters/cli/cli_test.go` | [API-2] | | `apps/node/internal/bootstrap/module.go` | [API-3] | ## 최종 검증 ```bash go mod tidy go test ./... ``` 기대 결과: 모든 Go package 테스트가 통과하고 `go.mod`/`go.sum`이 정리된 상태를 유지한다. 수동 검증(Claude CLI가 node host의 PATH에 있어야 함): ```bash # edge host ./bin/edge.sh # node host ./bin/node.sh # edge console /nodes hello ``` 기대 결과: - node 시작 직후 `cli` adapter가 `claude` persistent terminal process를 시작한다. - edge console의 `hello` 입력이 `RunRequest{adapter=cli, model=claude}`로 node에 전달된다. - node 콘솔에는 `[edge-message] hello`와 `[node-event] start/complete`가 표시된다. - edge 콘솔에는 `[node-local-node-event] start`, 하나 이상의 delta 누적 결과, `[node-local-node-event] complete`, `[node-local-node-message] ...`가 표시된다.