<!-- task=opencode_sse_stream plan=0 tag=OPENCODE_SSE -->

# Opencode SSE Stream Plan

## 이 파일을 읽는 구현 에이전트에게

**반드시 마지막에 `CODE_REVIEW-cloud-G07.md`의 모든 섹션을 실제 구현 내용과 검증 출력으로 채워야 한다. 이 파일 작성 전에는 작업이 완료된 것이 아니다.** 아래 체크리스트를 끝까지 수행하고, 중간/최종 검증 명령의 실제 stdout/stderr를 review 파일에 붙인다. review 파일의 `이 파일을 읽는 리뷰 에이전트에게` 섹션에 있는 archive 지시(`*.log` rename, `complete.log` 작성)는 구현 에이전트가 실행하지 않는다.

## 배경

`opencode run --format json`은 IOP의 stdout JSONL 파이프라인에 연결되어 있지만, OpenCode CLI 구현상 완성된 text part 중심으로 출력되어 토큰 단위 delta stream을 안정적으로 얻기 어렵다. OpenCode server는 `/event` SSE와 `/session/{id}/prompt_async`를 제공하며, 이 경로에는 `session.next.text.delta` 이벤트가 있다. IOP 공통 인터페이스는 `runtime.Adapter.Execute(ctx, spec, sink)`와 `RuntimeEvent`이므로, opencode 내부 실행 방식만 SSE client로 바꾸면 Edge/Node transport 계약은 유지된다.

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `go.mod`
- `apps/node/internal/adapters/cli/cli.go`
- `apps/node/internal/adapters/cli/oneshot.go`
- `apps/node/internal/adapters/cli/emitters.go`
- `apps/node/internal/adapters/cli/codex_exec.go`
- `apps/node/internal/adapters/cli/persistent.go`
- `apps/node/internal/adapters/factory.go`
- `apps/edge/internal/node/mapper.go`
- `packages/config/config.go`
- `proto/iop/runtime.proto`
- `configs/edge.yaml`
- `apps/node/README.md`
- `apps/edge/README.md`
- `apps/node/internal/adapters/cli/oneshot_blackbox_test.go`
- `apps/node/internal/adapters/cli/cli_internal_test.go`
- `apps/node/internal/adapters/factory_internal_test.go`
- `apps/edge/internal/node/mapper_test.go`
- `apps/edge/internal/transport/server_test.go`
- `packages/config/config_test.go`

### 테스트 커버리지 공백

- `opencode-json` stdout JSONL parsing은 `apps/node/internal/adapters/cli/oneshot_blackbox_test.go`와 `cli_internal_test.go`에 있다. SSE 기반 `session.next.text.delta` relay는 테스트가 없다.
- `CLI.Execute`의 mode 분기는 `codex-exec`, `persistent`, one-shot만 검증되어 있다. `opencode-sse` 분기, session reuse, `REQUIRE_EXISTING` 실패는 테스트가 없다.
- cancel 시 opencode `/session/{id}/abort` 호출과 IOP cancelled event 변환은 테스트가 없다.
- edge YAML의 `mode` 로딩은 `codex-exec` 예시만 있다. `opencode-sse` profile 예시는 없다.

### 심볼 참조

- renamed/removed symbols: none.
- 새 symbol 후보: `modeOpencodeSSE`, `opencodeSSESession`, `executeOpencodeSSE`, `parseOpencodeRunArgs`. 새 symbol이므로 기존 call site는 없다.

### 범위 결정 근거

- `runtime.Adapter`, `runtime.EventSink`, `RunEvent`, Edge-Node TCP/protobuf transport는 변경하지 않는다. 공통 인터페이스가 이미 stream relay 계약을 제공한다.
- `proto/iop/runtime.proto`는 변경하지 않는다. `CLIProfileConfig.mode`, `args`, `env`가 이미 edge에서 node로 전달된다.
- OpenCode 실제 binary나 실제 모델을 호출하는 통합 테스트는 제외한다. `httptest.Server`로 SSE/HTTP 계약을 검증하면 결정적이다.
- `ollama`, `vllm`, `mock`, `edge console` 실행 로직은 제외한다. opencode profile 내부 실행 방식만 바꾼다.

### 빌드 등급

- build=`cloud-G07`, review=`cloud-G07`: HTTP/SSE protocol parsing, session lifecycle, cancellation, cross-package config examples가 포함되어 local-only 검증보다 높은 리뷰가 필요하다.

## 의존 관계 및 구현 순서

1. `OPENCODE_SSE-1`: CLI adapter 내부 mode/session/SSE 실행기 구현.
2. `OPENCODE_SSE-2`: 설정 예시와 문서 갱신.
3. 각 항목 중간 검증 후 최종 전체 테스트.

### [OPENCODE_SSE-1] CLI adapter에 opencode SSE mode 추가

#### 문제

`CLI`는 persistent session map과 codex exec session map만 가진다.

```go
// apps/node/internal/adapters/cli/cli.go:55
type CLI struct {
	mu            sync.Mutex
	profiles      map[string]config.CLIProfileConf
	sessions      map[sessionKey]*profileSession
	codexSessions map[sessionKey]*codexExecSession
	logger        *zap.Logger
```

`Start`는 `codex-exec`만 persistent startup에서 제외한다.

```go
// apps/node/internal/adapters/cli/cli.go:93
names := make([]string, 0, len(c.profiles))
for name := range c.profiles {
	if c.profiles[name].Persistent && c.profiles[name].Mode != "codex-exec" {
		names = append(names, name)
	}
}
```

`Execute`는 `codex-exec`, persistent, one-shot만 분기한다.

```go
// apps/node/internal/adapters/cli/cli.go:153
func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
	agent := cliAgentName(spec)
	profile, ok := c.profiles[agent]
	if !ok {
		return fmt.Errorf("cli adapter: unknown agent %q", agent)
	}
	if profile.Mode == "codex-exec" {
		return c.executeCodexExec(ctx, spec, profile, sink)
	}
	if profile.Persistent {
		return c.executePersistent(ctx, spec, profile, sink)
	}
	return c.executeOneShot(ctx, spec, profile, sink)
}
```

`executeOneShot`은 prompt를 subprocess argv 뒤에 붙이는 구조라 `opencode run --format json`의 stdout event만 읽을 수 있다.

```go
// apps/node/internal/adapters/cli/oneshot.go:17
func (c *CLI) executeOneShot(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
	prompt := extractPrompt(spec.Input)
	args := append(append([]string{}, profile.Args...), prompt)
	_, err := c.executeCommand(ctx, spec, profile, args, prompt, sink)
	return err
}
```

#### 해결 방법

`apps/node/internal/adapters/cli/opencode_sse.go`를 새로 추가한다. mode별 파일을 둔 `codex_exec.go` 패턴을 따른다.

`cli.go`의 session state와 dispatch를 다음 형태로 확장한다.

```go
// after
const (
	modeCodexExec   = "codex-exec"
	modeOpencodeSSE = "opencode-sse"
)

type opencodeSSESession struct {
	key       sessionKey
	serverURL string
	sessionID string
	cmd       *exec.Cmd
	owned     bool
	mu        sync.Mutex
}

type CLI struct {
	mu               sync.Mutex
	profiles         map[string]config.CLIProfileConf
	sessions         map[sessionKey]*profileSession
	codexSessions    map[sessionKey]*codexExecSession
	opencodeSessions map[sessionKey]*opencodeSSESession
	logger           *zap.Logger
	// ...
}
```

`Execute`는 `opencode-sse`를 persistent/one-shot보다 먼저 처리한다.

```go
// after
if profile.Mode == modeCodexExec {
	return c.executeCodexExec(ctx, spec, profile, sink)
}
if profile.Mode == modeOpencodeSSE {
	return c.executeOpencodeSSE(ctx, spec, profile, sink)
}
```

`executeOpencodeSSE`는 다음 계약을 지킨다.

- `profile.Args`는 `opencode run` 호환 옵션으로 해석한다: `--attach`, `--model`, `--agent`, `--variant`, `--title`, `--dangerously-skip-permissions`, `--dir`.
- `--attach <url>`이 있으면 외부 server를 사용하고 process를 띄우지 않는다.
- `--attach`가 없으면 `profile.Command serve --hostname 127.0.0.1 --port 0`을 실행하고 stdout/stderr에서 `opencode server listening on http://...`를 파싱한다.
- `SessionModeRequireExisting`이고 `(target, session_id)`에 cached opencode session이 없으면 codex/persistent와 같은 형태의 error를 반환한다.
- `GET /event` SSE를 먼저 열고, session이 없으면 `POST /session`으로 생성한다.
- `POST /session/{id}/prompt_async` body는 `{parts:[{type:"text",text:prompt}], model?, agent?, variant?}`로 보낸다.
- SSE `session.next.text.delta`는 `RuntimeEvent{Type: delta, Delta: properties.delta}`로 즉시 emit한다.
- SSE `session.next.step.ended`의 `tokens.input/output`은 complete event usage에 보관한다.
- SSE `session.status` idle 또는 `session.idle`에서 `RuntimeEvent{Type: complete, Message:"opencode sse execution complete"}`를 emit하고 종료한다.
- SSE `session.error`는 error event emit 후 error return한다.
- `permission.asked`는 `--dangerously-skip-permissions`가 있으면 `/permission/{id}/reply` with `{"reply":"once"}`, 없으면 `{"reply":"reject"}`로 답한다.
- `ctx.Done()`이면 `/session/{id}/abort`를 best-effort로 호출하고 cancelled event를 emit한 뒤 `runtime.ErrRunCancelled`를 반환한다.

`Stop`과 `TerminateSession`은 `opencodeSessions`도 닫는다. owned local server process는 kill하고, attach session은 process kill 없이 cache만 제거하며 가능하면 abort를 호출한다.

#### 수정 파일 및 체크리스트

- [ ] `apps/node/internal/adapters/cli/cli.go`: mode constants, `opencodeSessions` map, `Start` skip, `Execute` branch, `Stop`, `TerminateSession` 확장.
- [ ] `apps/node/internal/adapters/cli/opencode_sse.go`: HTTP/SSE client, option parser, local server startup, session resolve, prompt send, event loop, abort/close helpers 구현.
- [ ] `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go`: httptest 기반 execute/cancel/error/session tests 추가.
- [ ] `apps/node/internal/adapters/cli/opencode_sse_internal_test.go`: args parser, SSE data parser unit tests 추가.

#### 테스트 작성

- 작성한다.
- `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go`
  - `TestCLIExecuteOpencodeSSE_StreamsTextDeltas`: fake `/event`, `/session`, `/prompt_async`로 `session.next.text.delta` 2개와 idle을 보내고 delta concat, complete, usage를 검증한다.
  - `TestCLIExecuteOpencodeSSE_RequireExistingWithoutSessionErrors`: cached session이 없고 `SessionModeRequireExisting`이면 error.
  - `TestCLIExecuteOpencodeSSE_SessionErrorEmitsRuntimeError`: `session.error`를 error event와 error return으로 검증한다.
  - `TestCLIExecuteOpencodeSSE_ContextCancelAbortsSession`: context cancel 시 `/session/{id}/abort` 호출, cancelled event, `runtime.ErrRunCancelled`.
- `apps/node/internal/adapters/cli/opencode_sse_internal_test.go`
  - `TestParseOpencodeRunArgs`: `--attach`, `--model provider/model`, `--agent`, `--variant`, `--dangerously-skip-permissions` parsing.
  - `TestDecodeSSEDataLine`: `data: {...}` parsing과 non-data skip.

#### 중간 검증

```bash
go test -count=1 ./apps/node/internal/adapters/cli
```

예상 결과: package pass. Go test cache는 허용하지 않는다.

### [OPENCODE_SSE-2] opencode profile 예시와 문서를 SSE mode로 정리

#### 문제

예시 config는 opencode를 `run --format json` stdout JSONL mode로 둔다.

```yaml
# configs/edge.yaml:85
          opencode:
            command: "/config/.npm-global/bin/opencode"
            args:
              - "run"
              - "--title"
              - "untitle"
              - "--model"
              - "ollama-dgx/qwen3.6:35b-a3b-bf16"
              - "--format"
              - "json"
              - "--dangerously-skip-permissions"
            env: []
            persistent: false
            terminal: false
            output_format: "opencode-json"
```

`CLIProfileConf`에는 이미 mode string이 있어 새 config field 없이 opencode 전용 구현을 선택할 수 있다.

```go
// packages/config/config.go:124
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"`
	OutputFormat          string               `mapstructure:"output_format"            yaml:"output_format"`
	CompletionMarker      CompletionMarkerConf `mapstructure:"completion_marker"        yaml:"completion_marker"`
	Mode                  string               `mapstructure:"mode"                     yaml:"mode"`
	ResumeArgs            []string             `mapstructure:"resume_args"              yaml:"resume_args"`
}
```

#### 해결 방법

`configs/edge.yaml`의 opencode profile을 `mode: "opencode-sse"`로 바꾸고 `args`는 prompt 전송 옵션만 남긴다.

```yaml
# after
          opencode:
            command: "/config/.npm-global/bin/opencode"
            args:
              - "--title"
              - "untitle"
              - "--model"
              - "ollama-dgx/qwen3.6:35b-a3b-bf16"
              - "--dangerously-skip-permissions"
            env: []
            persistent: false
            terminal: false
            mode: "opencode-sse"
```

`apps/node/README.md`와 `apps/edge/README.md`는 opencode 예시를 `opencode serve` + SSE mode로 설명한다. stdout JSONL `opencode-json` emitter는 legacy/run mode로 남긴다.

#### 수정 파일 및 체크리스트

- [ ] `configs/edge.yaml`: opencode profile `mode: "opencode-sse"` 적용, `run`/`--format json` 제거.
- [ ] `apps/node/README.md`: CLI adapter 설명에 opencode는 `opencode-sse` mode에서 server SSE를 사용한다고 기록.
- [ ] `apps/edge/README.md`: 수동 테스트 예시의 opencode 명령 설명 갱신.
- [ ] `packages/config/config_test.go`: `TestLoadEdge_OpencodeSSEProfile` 추가.
- [ ] `apps/edge/internal/node/mapper_test.go`: `TestBuildConfigPayload_CLIProfiles` 또는 새 테스트에서 `Mode` 전달 검증.
- [ ] `apps/node/internal/adapters/factory_internal_test.go`: proto/legacy settings에서 `mode`가 node CLI config로 들어오는지 검증.

#### 테스트 작성

- 작성한다.
- `packages/config/config_test.go`
  - `TestLoadEdge_OpencodeSSEProfile`: YAML에서 `mode: "opencode-sse"`, `--model`, `--dangerously-skip-permissions`가 로드되는지 확인.
- `apps/edge/internal/node/mapper_test.go`
  - `Mode`와 `ResumeArgs`가 `CLIProfileConfig`로 전달되는지 확인.
- `apps/node/internal/adapters/factory_internal_test.go`
  - typed proto와 legacy struct fallback 모두 `mode` 값을 유지하는지 확인.

#### 중간 검증

```bash
go test -count=1 ./packages/config ./apps/edge/internal/node ./apps/node/internal/adapters
```

예상 결과: package pass. Go test cache는 허용하지 않는다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/node/internal/adapters/cli/cli.go` | OPENCODE_SSE-1 |
| `apps/node/internal/adapters/cli/opencode_sse.go` | OPENCODE_SSE-1 |
| `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go` | OPENCODE_SSE-1 |
| `apps/node/internal/adapters/cli/opencode_sse_internal_test.go` | OPENCODE_SSE-1 |
| `configs/edge.yaml` | OPENCODE_SSE-2 |
| `apps/node/README.md` | OPENCODE_SSE-2 |
| `apps/edge/README.md` | OPENCODE_SSE-2 |
| `packages/config/config_test.go` | OPENCODE_SSE-2 |
| `apps/edge/internal/node/mapper_test.go` | OPENCODE_SSE-2 |
| `apps/node/internal/adapters/factory_internal_test.go` | OPENCODE_SSE-2 |

## 최종 검증

```bash
go test -count=1 ./apps/node/internal/adapters/cli ./apps/node/internal/adapters ./apps/edge/internal/node ./apps/edge/internal/transport ./packages/config
```

예상 결과: package pass. Go test cache는 허용하지 않는다.

```bash
go test -count=1 ./...
```

예상 결과: repository-wide pass. Go test cache는 허용하지 않는다.

```bash
rg --sort path -n 'opencode-sse|opencode-json|opencode run --format json|session.next.text.delta' apps/node apps/edge configs packages agent-task/opencode_sse_stream
```

예상 결과: `opencode-sse`는 새 mode/문서/테스트에 나타난다. `opencode-json`과 `opencode run --format json`은 legacy stdout JSONL 경로로 의도적으로 남은 항목만 나타난다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 전체 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
