작업이 <!-- task=cli_terminal_cycle plan=0 tag=API -->

# 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:<prompt>` 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] ...`가 표시된다.
