iop/agent-task/cli_terminal_cycle/plan_0.log

610 lines
22 KiB
Text

작업이 <!-- 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] ...`가 표시된다.