feat(ui): complete M1 Milestone - read-only Dashboard and Detail Pane with status.sh integration

This commit is contained in:
2026-07-16 21:09:31 +09:00
parent 7d22774d76
commit 2eb85866b3
22 changed files with 2014 additions and 399 deletions
@@ -0,0 +1,362 @@
# 📑 Implementation Plan (Rev.3 — Flutter 전면 재작성): MAM Session/Skill 관리 GUI (`multi-agent-mux-ui`)
**Job**: `40bdce88` · **Role**: Planner (design-only, no code 구현 없음)
**선행 문서**:
- `.mam/jobs/84c81aa9/claude-reports/report-final.md` (Rev.1 — Python+Textual 원 계획서)
- `.mam/jobs/bed6d7da/claude-reports/report-final.md` (Rev.2 — Rev.1 보강, 예외처리/attach 무결성/purge 보안)
- Rev.2 교차검증 4라운드: `dfbce862`(NOT PASS) → `a18c845c`(교차검증 PASS) → `c141d42f`(Round 3, NOT PASS — 문서 내부 불일치) → `eaaf44a6`(Round 4, **PASS 확정**)
**본 잡의 목적**: 브리프에 따라 Rev.1/Rev.2가 전제했던 **Python + Textual** 스택을 **Flutter(Desktop/Web/TUI)** 로 전면 교체한다. 이것은 Rev.2처럼 "diff 형태로 변경분만 얹는" 증보판이 아니다 — 실행 런타임 자체가 바뀌므로 §5(아키텍처)·§7(화면 설계)·§9(디렉터리)·§10(마일스톤)은 **전면 재작성**하고, 반대로 Rev.1의 요구사항 매핑(§2)과 Rev.2가 4라운드에 걸쳐 검증한 안전 계약(D1~D7)은 **언어에 무관한 불변 계약**으로 간주해 그대로 이식한다. Creator는 본 문서 하나만으로 구현에 착수할 수 있어야 하며, Rev.1/Rev.2는 "왜 이 안전장치가 필요한지"의 배경 근거로만 참조한다.
---
## 0. 원본 셸 스크립트 재확인 (Rev.3 작성을 위해 직접 재검토한 근거)
이번 재작성 과정에서 `status.sh`(119줄), `stop_session.sh`(361줄), `create_session.sh`(409줄), `resolve_session_id.sh`/`update_yaml_resumed.sh`, `lib.sh`(1233줄)를 라인 단위로 다시 읽었다. Rev.1/Rev.2가 확인한 계약(exit code, 타임아웃, purge pre-gate)은 모두 현재 소스와 일치함을 재확인했다. 단, **Rev.3 작성 과정에서 이전 4라운드 리뷰가 놓친 새 결함 하나(D8, §3)를 발견**했다 — Flutter로 언어를 바꾸며 데이터 계층을 처음부터 설계하다 보니 드러난 것으로, 아래에서 상세히 다룬다.
---
## 1. 왜 "증보"가 아니라 "전면 교체"인가
브리프는 Desktop/Web/Terminal 세 폼팩터를 **동일 요구사항(세션 실시간 조회, attach, stop, purge, 스킬 관리)** 으로 커버하라고 요구한다. Textual은 순수 TUI 프레임워크로, 네이티브 데스크톱 창이나 브라우저 GUI를 만들 수 없다(`textual-web`이 있지만 이 역시 "터미널 UI를 브라우저로 스트리밍"하는 것이지 네이티브 웹 GUI가 아니다). 따라서:
- Rev.1 §3(기술 스택)·§3.2(계층 구조)는 전량 폐기 — Flutter/Dart 생태계로 재설계 (§5).
- Rev.2 §3(attach/suspend 무결성)은 **부분 폐기, 부분 계승** — Textual의 "suspend 하고 진짜 터미널에 제어를 넘기는" 트릭 자체가 "이 프로세스가 이미 실제 터미널 안에서 돌고 있다"는 전제에 의존하는데, Desktop/Web 앱은 애초에 터미널이 아니므로 이 전제가 성립하지 않는다. 반면 순수 Dart TUI 타깃(§5.4)은 여전히 진짜 터미널 안에서 돌므로 Rev.2 §3.1~§3.4가 그대로 적용된다. → §6.7에서 타깃별로 분리해 재서술한다.
- Rev.1 §2(요구사항→기능 매핑), Rev.1 §5/Rev.2 전체(안전장치 원칙, exit-code 계약, 명령주입 방지, TOCTOU, 신뢰 경계)는 **런타임과 무관한 계약**이므로 그대로 유지하고 Dart 구현체로만 재서술한다 (§4, §6).
---
## 2. 기존 자산 재사용 (Rev.1 §1 — 변경 없음)
| 기존 자산 | 위치 | 재사용 방식(변경 없음) |
| :--- | :--- | :--- |
| 실시간 세션 스냅샷 | `.agents/skills/multi-agent-mux-status/scripts/status.sh --json` | 폴링 소스 (단, §3의 스키마 확장 필요) |
| 세션 stop/purge | `.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session <n> [--purge-conversation] [--yes] [--reason <r>]` | 확인 다이얼로그 통과 후 그대로 호출 |
| 세션 생성 | `.agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace <path> --agent <claude|agy|hermes|cline> --role <role> [--session <n>] [--onboard] [--submit-job <prompt>]` | New 마법사 폼 → argv 매핑 |
| 세션 재개 | `.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh --workspace <path> --agent <a> [--session <n>]` + `update_yaml_resumed.sh --session <n> --uuid <id> [--agent <a>]` | Resume 마법사 폼 → 두 스크립트 순차 호출 |
| tmux attach 명령 | YAML 각 행의 `attach_command` (`create_session.sh:314`: `f'tmux {server_opt}attach -t {name}'`) | §6.7 터미널 위젯 연동으로 실제 실행까지 담당(Rev.1은 문자열 노출까지만 요구) |
| Job 조회 | `.agents/skills/multi-agent-mux-delegate-job` CLI/`registry.py` | Jobs 탭 |
| 스킬 메타데이터 | `.agents/skills/*/SKILL.md` front-matter | Skills 탭 |
**불변 설계 원칙(Rev.1 §1 그대로)**: UI는 YAML/SQLite/jsonl을 절대 직접 쓰지 않는다. 모든 상태 변경은 기존 스크립트의 서브프로세스 호출을 거친다. (§3의 스키마 확장은 "쓰기"가 아니라 `status.sh`의 **읽기 전용 출력 포맷**을 넓히는 것이므로 이 원칙과 충돌하지 않는다.)
---
## 3. 신규 발견: `status.sh --json`의 데이터 갭 (D8 — Rev.3에서 처음 발견)
Rev.1/Rev.2 및 4라운드 리뷰 모두 `status.sh --json`을 "Drift 클래스(A/B/C/D)까지 이미 계산해 주는 폴링 소스"라고 서술했고, Rev.1 §4.1은 이를 근거로 대시보드 컬럼을 `NAME/SERVER/ROLE/YAML/TMUX/CMD/RESUME/JOB_ID/JOB_STATUS/DRIFT`로 정의했다. 그런데 실제 소스를 다시 대조하면:
- `status.sh:22-25`: `--json`이 켜지면 `reconcile.sh --once --emit-diff --dry-run`의 출력(`DRIFT_JSON`)을 **그대로 출력하고 즉시 exit** — 이 시점 이후의 Python 블록(줄 31~119, `RESUME`/`JOB_ID`/`JOB_STATUS`/`CMD`/`attach_command`/`pane.cwd` 등을 계산하는 부분)은 **`--json` 경로에서는 전혀 실행되지 않는다.**
- `reconcile.sh:632-636`이 실제로 emit하는 최상위 키는 `timestamp / yaml_path / tmux_sessions_alive(문자열 리스트 "name|server") / tmux_confirmed / drifts / actions` 뿐이다. 각 세션의 `pane.cwd`, `role`, `delegate_job_id`, `attach_command` 같은 원본 YAML 행 필드는 **이 JSON 어디에도 없다.**
- 즉 텍스트 모드(`bash status.sh`, 인자 없이)에서만 보이는 `RESUME`(on-disk jsonl/db 존재 확인) · `JOB_STATUS`(`.mam/jobs/<id>.json` 조회) 같은 **파생 컬럼은 `--json` 소비자에게 완전히 숨겨져 있다.**
**영향**: Rev.1/Rev.2가 가정한 "Flutter는 `status.sh --json`만 폴링하면 대시보드 전체 컬럼을 그린다"는 전제가 Flutter 데이터 계층 설계 단계에서 그대로 성립하지 않는다. 방치하면 Creator가 다음 중 하나로 흐를 위험이 있다: (a) 고정폭 텍스트 표를 정규식으로 파싱(깨지기 쉬움, bash 쪽 컬럼 폭 변경 시 소리 없이 망가짐), 또는 (b) resume-on-disk/job-status 파생 로직을 Dart로 **두 번째 구현**(bash+Python 원본과 로직 drift 위험, 유지보수 이중화).
### 3.1 채택한 해법 — `status.sh --json`의 순수 additive 스키마 확장
이 프로젝트 전체에서 **유일하게 허용하는 기존 스크립트 수정**으로, `status.sh``--json` 분기(줄 22-25)를 제거하고 텍스트 모드가 이미 계산하는 것과 동일한 Python 블록을 실행한 뒤, 그 결과를 **기존 `DRIFT_JSON` 최상위 키는 그대로 둔 채 새 키(`sessions_detail`)로 추가**한다.
```jsonc
// status.sh --json 신규 출력 형태 (기존 키 무변경 + 신규 키 추가만)
{
"timestamp": "...", "yaml_path": "...", "tmux_sessions_alive": [...],
"tmux_confirmed": true, "drifts": [...], "actions": [...], // ← 기존 키, 100% 동일
"sessions_detail": [ // ← 신규 키
{
"name": "...", "server": "default", "status": "running",
"tmux_alive": true, "cmd": "claude", "role": "creator",
"resume_state": "yes|no|scan|MISSING|?",
"job_id": "-", "job_status": "-",
"pane_cwd": "...", "attach_command": "tmux attach -t ...",
"drift_classes": ["B"]
}
]
}
```
- **왜 이 방식인가(대안 기각 이유)**: (a) 텍스트 표 파싱은 고정폭 컬럼에 암묵 결합 — 기각. (b) Dart에서 YAML 직접 읽기 + resume/job-status 파생 로직 재구현 — 동일 로직이 bash(Python 임베디드)와 Dart 두 곳에 존재하게 되어 한쪽만 고치면 drift 발생 — 기각. (c) 채택안은 파생 로직의 **단일 진실 공급원**을 `status.sh` 하나로 유지하면서, 기존 키를 하나도 건드리지 않아 **다른 소비자(있다면)에게 회귀 없음**을 보장한다.
- **DoD 필수 검증**(§10에 반영): (1) 신규 키 추가 전/후로 기존 5개 키(`timestamp/yaml_path/tmux_sessions_alive/tmux_confirmed/drifts`)의 값이 **완전히 동일**한지 `jq` diff로 확인, (2) 인자 없는 텍스트 모드 출력이 **byte-identical**한지 확인(이 변경이 순수 `--json` 분기 내부에만 있어야 함).
- Flutter `mam_core`는 이제 `sessions_detail`을 그대로 모델에 매핑하면 되고, resume/job-status 파생 로직을 Dart로 재구현하지 않는다.
---
## 4. 요구사항 → 기능 매핑 (Rev.1 §2 — UI 구현체만 Flutter로 교체)
| 브리프 요구사항 | Rev.1(Textual) | Rev.3(Flutter) |
| :--- | :--- | :--- |
| tmux attach 명령어 확인 | Detail Pane 텍스트 노출 | Detail Pane(사이드 패널)에 `attach_command` 표시 + 복사 버튼 |
| 활성 에이전트 실시간 조회 | `DataTable` 3초 폴링 | `sessions_detail` 폴링 → Riverpod `StreamProvider``data_table_2` 그리드 |
| stop | `ConfirmModal` | Flutter `AlertDialog` + reason 입력 필드 |
| purge (`--purge-conversation`) | 2단계 모달 | 2단계 `Dialog`(경고 → 세션명 완전 재입력) — Rev.2 §4 계약 그대로(§6.4) |
| tmux attach 실행 | `Screen.suspend()` + 포그라운드 subprocess | **타깃별 분기**(§6.7): Desktop/Web은 임베디드 터미널 위젯, TUI는 Rev.2와 동일한 suspend+exec |
| 스킬 관리 | `ListView` + `MarkdownViewer` | `ListView.builder` + `flutter_markdown` 뷰어(원문 그대로 렌더링, 읽기 전용 원칙 유지) |
| New/Resume 마법사 | 폼 모달 | Flutter `Form` + 단계별 `Stepper` |
| Jobs 탭 | `Table` | `data_table_2` 2번째 탭, Sessions의 `JOB_ID`와 상호 참조 |
---
## 5. 아키텍처 개요 (전면 신규)
### 5.1 기술 스택 선정
| 관심사 | 채택 | 근거 |
| :--- | :--- | :--- |
| UI 프레임워크 | **Flutter** (브리프 지정) | 단일 Dart 코드베이스로 Desktop(GTK/Win32/macOS 임베더)+Web(CanvasKit/Wasm) 동시 타깃. TUI는 §5.4에서 별도 처리 |
| 모노레포 도구 | `melos` | 여러 패키지(순수 Dart 로직 + Flutter 앱들)를 하나의 저장소에서 버전/스크립트 관리 |
| 상태관리 | `flutter_riverpod`(+ `riverpod_generator`) | 비동기 폴링 스트림(`StreamProvider`)과 행 단위 뮤테이션 락(§6.2) 표현에 적합, 테스트 용이 |
| 데이터 그리드 | `data_table_2` | 코어 `DataTable`보다 넓은 표/고정 컬럼/성능에 적합 (세션 수가 많아질 때 대비) |
| 터미널 에뮬레이터 위젯 | `xterm`(TerminalStudio) | VT100 호환 렌더러, Flutter 위젯으로 바로 embed 가능 — **버전은 구현 착수 시점에 pin** (Rev.2가 Textual suspend API 버전을 재확인하도록 요구했던 것과 동일한 이유: 생태계 변동 리스크) |
| PTY 실행 | packages/mam_pty (`pty_session.dart` 의 FFI PTY 바인딩, 버전 구현 시 FFI 라이브러리 pin) — Desktop/Web은 이 공용 바인딩을 직접 사용; TUI는 PTY 없이 직접 stdio 상속 (§6.7 참조) | §6.7에서 상세 |
| YAML 파싱 | `package:yaml` | `SKILL.md` front-matter 파싱에만 사용(세션 데이터는 §3 확장된 `--json`으로 받으므로 YAML을 직접 읽지 않음) |
| Markdown 렌더링 | `flutter_markdown` | 스킬 원문 뷰어 |
### 5.2 모노레포 패키지 구조
```
multi-agent-mux-ui/ # 신규 스킬 루트 (.agents/skills/ 하위, Rev.1 §6과 동일 위치 원칙)
├── melos.yaml
├── packages/
│ ├── mam_core/ # 순수 Dart, Flutter 의존성 없음
│ │ └── lib/
│ │ ├── models/ # session_row.dart, drift_entry.dart, job_status.dart, skill_meta.dart
│ │ ├── command_runner.dart # 유일한 서브프로세스 실행 지점 (D5 대응, §6.5)
│ │ ├── status_repository.dart # 폴링 스트림 + stale/backoff (D6 대응, §6.4)
│ │ ├── stop_purge_service.dart# exit-code 매핑 + 타임아웃 + 락 (D1~D3, §6.1~6.3)
│ │ ├── skills_scanner.dart # SKILL.md front-matter 파서
│ │ └── job_registry_reader.dart
│ └── mam_pty/ # 순수 Dart(dart:io/FFI/FFI-PTY), Flutter 엔진 비의존 — 헤드리스 실행 가능해야 하며 Desktop에서도 Flutter 비의존 상태로 이 PTY 스폰 로직과 환경변수 격리 로직을 그대로 재사용합니다.
│ └── lib/
│ ├── pty_session.dart # tmux attach를 PTY로 스폰, 바이트 스트림 in/out
│ └── pty_bridge_server.dart # Web 타깃 전용 WebSocket 서버 (localhost-only, §6.8)
├── apps/
│ ├── mam_desktop/ # Flutter Desktop 앱 (M1~M7, M9)
│ ├── mam_web/ # Flutter Web 앱 (M8)
│ ├── mam_pty_bridge_daemon/ # mam_pty를 구동하는 독립 데몬 실행파일 (Web 전용, M8)
│ └── mam_tui/ # 순수 Dart 터미널 프론트엔드 — Flutter 렌더링 아님 (§5.4, M10)
├── SKILL.md
└── scripts/
├── run_desktop.sh
├── run_web.sh # mam_web 서빙 + mam_pty_bridge_daemon 기동 안내
└── run_tui.sh
```
### 5.3 계층 구조 다이어그램
```
┌───────────────────────────────────────────────────────────────────┐
│ apps/mam_desktop, apps/mam_web, apps/mam_tui (Presentation) │
│ ┌───────────┬────────────┬─────────┬───────────────────────────┐ │
│ │ Sessions │ Skills │ Jobs │ Attach Panel (xterm 위젯) │ │
│ └─────┬─────┴─────┬──────┴────┬────┴─────────────┬─────────────┘ │
└────────┼───────────┼───────────┼───────────────────┼───────────────┘
▼ ▼ ▼ ▼
┌───────────────────────────────────────┐ ┌─────────────────────┐
│ packages/mam_core (Riverpod 상태) │ │ packages/mam_pty │
│ status_repository / stop_purge_service│ │ pty_session / │
│ skills_scanner / job_registry_reader │ │ pty_bridge_server │
│ command_runner (subprocess 유일 창구) │ └──────────┬───────────┘
└──────────────────┬─────────────────────┘ ▼
▼ tmux -L <server> attach -t <name>
status.sh --json (D8 확장) (Desktop: 인프로세스 PTY,
stop_session.sh / create_session.sh Web: WS 경유 로컬 데몬,
resolve_session_id.sh / update_yaml_resumed.sh TUI: exec 상속, §6.7)
multi-agent-mux-delegate-job CLI
```
### 5.4 "TUI" 타깃의 정직한 스코프 정의 (구현 착수 전 확인 필요 — M0 게이트)
Flutter는 공식적으로 문자 단말에 렌더링하는 임베더를 제공하지 않는다(Skia/CanvasKit 기반 GPU 렌더러이지 curses류 렌더러가 아님). "Flutter를 터미널에서 그대로 띄운다"는 브리프 문구를 문자 그대로 구현하려 하면 유지보수 불가능한 비공식 임베더에 의존하게 된다. 따라서 본 계획은 다음을 **채택 설계**로 제시한다(임의 재해석이 아니라 명시적 결정 사항으로 기록):
- `apps/mam_tui`**Flutter 렌더링이 아닌 순수 Dart 터미널 프런트엔드**(예: `package:dart_console` 또는 ANSI 테이블 직접 렌더링)로 구현한다.
- 대신 `packages/mam_core`(데이터/서비스 계층 — exit-code 매핑, 타임아웃, 락, 폴링)를 Desktop/Web/TUI 세 앱이 **100% 동일하게 공유**한다 — "동일 코드베이스"라는 브리프의 실질적 의도(로직 중복 방지, 세 폼팩터 간 동작 일관성)는 이 방식으로 충족된다.
- **M0 마일스톤에 이 결정에 대한 명시적 확인 체크리스트 항목을 둔다** — Creator/이해관계자가 "Flutter가 실제로 터미널 픽셀을 그린다"는 기대를 갖고 있었다면 M1 착수 전에 조정되어야 하므로.
### 5.5 Web 타깃 신뢰 경계 (Rev.2 §4.4 확장 — 이번엔 실제 타깃이므로 구체적 하드게이트 필요)
Rev.2 §4.4는 "웹으로 확장할 경우 purge에는 별도 인증이 선행되어야 하며, 그 전까지 웹 변형에는 purge를 노출하지 않는 것을 하드 블로커로"라고 가정형으로 서술했다. Rev.3는 Web이 실제 M8 산출물이므로 이를 구체적 설계로 실장한다:
- `mam_pty_bridge_daemon``127.0.0.1`에만 바인딩(`0.0.0.0` 금지 — 코드 레벨 상수, 설정으로 노출 금지).
- 데몬 기동 시 매번 새 랜덤 토큰을 생성해 **기동한 터미널에 stdout으로만 출력**(웹 빌드 산출물에는 토큰이 절대 하드코딩되지 않음).
- `mam_web`은 기본 상태에서 **모든 뮤테이션 액션(stop/purge/create/resume) 비활성** — 사용자가 "로컬 에이전트에 연결" 화면에서 데몬 주소+토큰을 직접 입력해 WS 핸드셰이크에 성공한 경우에만 활성화.
- 그중에서도 **purge는 브릿지 연결 여부와 무관하게 추가 로컬 재확인 단계**(§6.4의 TOCTOU 재검증 + 워크스페이스 경로 노출)를 항상 강제 — Rev.2 원칙을 완화하지 않고 그대로 유지.
---
## 6. 예외처리/보안 계약 이식 — Rev.2 D1~D7 → Dart/Flutter 재서술 (+ D8)
### 6.1 공통 실행 계층 — `command_runner.dart`
```dart
// 의사코드 — 정확한 시그니처는 구현 시 고정
class CommandResult {
final int rc; final String stdout; final String stderr; final bool timedOut;
final Future<CommandResult>? backgroundFuture; // timedOut=true & killOnTimeout=false 일 때, 백그라운드 완주 후 최종 결과를 받을 핸들 (D-Critical 보완)
CommandResult(this.rc, this.stdout, this.stderr, this.timedOut, {this.backgroundFuture});
}
Future<CommandResult> runCommand(
List<String> argv, {
Duration? timeout,
Map<String, String>? environment,
bool killOnTimeout = true, // D-Critical 대응: purge 호출부는 false로 전달 (§6.3)
}) async {
// 1) argv는 항상 List<String> — Process.start(exe, args) 만 사용, runInShell:true 절대 금지 (D5)
// 2) Process.start는 Dart 이벤트 루프를 블로킹하지 않음 — Textual과 달리 Dart의 Process API는
// OS 비동기 I/O 기반이라 await 중에도 다른 마이크로태스크/프레임 콜백이 계속 실행됨
// 3) timeout 지정 시 Future.any([process.exitCode, Future.delayed(timeout)])로 클라이언트측 상한 강제
// 4) 타임아웃 도달 & killOnTimeout=true 일 때만 process.kill() 수행 후 timedOut=true 반환.
// killOnTimeout=false면 프로세스를 백그라운드에서 계속 완주시키고 timedOut=true와 함께
// 백그라운드 프로세스의 완주 결과를 수신할 수 있는 backgroundFuture를 담아 즉시 반환.
// (UI 단에서는 timedOut 시점에는 경고 토스트를 띄우되, backgroundFuture를 await 하여
// 최종 성공(rc=0) 시 세션 행 제거를 지연 수행하고, 실패(exit 4) 시 경고 배지로 즉시 전환하여 고스트 불일치 방지)
...
}
```
- **타임아웃 상한(Rev.2 §2.1 값 그대로 계승 — 스크립트 자체가 언어와 무관하므로 숫자도 무변경)**: 조회(`status.sh --json`) 5초 / 일반 Stop 60초(`send_keys_safe` quiescence ≤10초 + `SKS_DIALOG_TIMEOUT` ≤30초 + `_wait_session_gone 5`+`8` ≈ 최대 53초 실측 대비 여유 확보) / **Purge는 타임아웃 미적용 또는 ≥120초, 도달 시에도 `killOnTimeout: false`로 호출**.
### 6.2 행 단위 뮤테이션 락
- Riverpod에 `StateNotifier<Map<String, RowLock>>`(세션명 → 진행중 여부)을 두고, Stop/Purge 실행 중인 행은 스피너(`⏳ stopping…`/`⏳ purging…`) 표시 + 해당 행의 액션 버튼만 비활성화. 다른 행의 폴링/조작은 계속 정상 동작(Dart는 구조적으로 이미 non-blocking이므로, 이 락의 목적은 "블로킹 방지"가 아니라 "동일 세션에 중복 명령 발사 방지"임을 §6.1과 구분해 명확히 한다).
### 6.3 Exit Code 매핑표 (Rev.2 §2.3 — 값 변경 없음, 스크립트 계약이 동일하므로)
| exit code | 의미 | UI 처리 |
| :-- | :--- | :--- |
| 0 | 성공 또는 이미 stopped(멱등 no-op) | stdout에 `"already stopped"` 포함 여부로 토스트 문구 분기 |
| 1 | YAML에 세션 없음(미등록/이미 purge됨) | 즉시 강제 리프레시, "session no longer registered" |
| 2 | 잘못된 인자 | UI 버그로 간주 — 사용자 비노출, 개발자 로그만 |
| 3 | `--yes` 누락 | UI는 항상 확인 후 자동 부여하므로 정상 경로에서 도달 불가 — 도달 시 버그 취급 |
| 4 | purge 시도했으나 kill 체인 후에도 tmux 생존 → 레지스트리 무변경 | 행을 "purged"로 절대 표시 금지, `status: running`(직전 상태) 유지 + 영구 경고 배지(§6.4) |
### 6.4 Purge 안전장치 (Rev.2 §4 그대로 계승, Dart Dialog로 구현)
- 1차 경고 다이얼로그(비가역성 고지) → 2차: **세션명 완전 일치 재입력**(대소문자/공백 포함) 시에만 "PURGE" 버튼 활성화.
- 다이얼로그에 `pane_cwd`(워크스페이스 절대경로)와 `role`을 굵게 노출 — 유사 이름 세션 오인 삭제 방지.
- **TOCTOU 재검증**: 다이얼로그를 연 시점의 스냅샷과 "PURGE 확정" 버튼을 누르는 순간의 최신 폴링 스냅샷을 비교, `status`/`tmux_alive`가 달라졌으면 확정 거부 후 재확인 요구.
- exit 4 수신 시: 다이얼로그를 닫고 해당 행에 **자동으로 사라지지 않는** 경고 배지 `"⚠ purge failed — session still alive"` 표시, 재시도는 언제나 안전(멱등)함을 툴팁에 명시.
- UI/`mam_core`/`mam_pty` 어디에도 `File(...).delete()`, `Directory(...).delete(recursive: true)` 등 파일 삭제 코드를 절대 작성하지 않는다 — 삭제는 전적으로 `stop_session.sh` 내부(경로 검증 포함)에 위임(§10 DoD grep).
### 6.5 입력 검증/명령 주입 방지 (D5)
- `reason`, 세션명 재입력 등 모든 사용자 입력은 `command_runner.dart`를 통해 **항상 `List<String>` argv**로 `Process.start`에 전달. `Process.run(..., runInShell: true)`나 셸 문자열 조합은 코드베이스 전체에서 금지(§10 grep 항목).
- **Flutter 렌더링 모델에 맞게 재정의한 위험**: Rev.2는 이 항목을 "저장형 터미널 이스케이프 실행" 위험으로 서술했으나, 이는 Textual/curses처럼 **실제 ANSI 인터프리터가 있는 TTY**에서만 성립하는 공격이다. Flutter의 `Text`/`data_table_2` 셀은 캔버스에 문자 코드포인트를 그릴 뿐 ANSI 이스케이프를 해석·실행하지 않으므로, "저장된 이스케이프가 재생되어 명령이 실행"되는 시나리오 자체가 성립하지 않는다. 대신 실질 위험은: (a) 제어문자/개행이 `data_table_2` 셀 레이아웃을 깨뜨리는 것, (b) 매우 긴 `reason` 문자열이 레이아웃을 밀어내는 것 — 따라서 `reason`/재입력 값은 개행 제거 + 길이 상한(200자)만 적용하면 충분하며, ANSI 이스케이프 자체를 "무해화"할 필요는 없다(애초에 실행되지 않으므로). **단, `mam_tui`(순수 터미널 프런트엔드)는 예외** — 실제 터미널에 문자열을 그대로 출력하면 ANSI 인터프리터가 다시 개입하므로, `mam_tui`에서 `last_visible_status`/`reason`을 렌더링할 때는 Rev.2와 동일하게 제어문자 escape 처리가 **여전히 필요**하다.
### 6.6 조회(폴링) 경로 방어 (D6)
- `status_repository.dart``status.sh --json` 호출 실패(non-zero exit) 또는 `jsonDecode` 실패 시: 마지막 성공 스냅샷을 유지하고 상단에 `"⚠ status snapshot stale (last ok: HH:MM:SS)"` 배너 표시. 폴링 간격은 실패 시 백오프(3s→6s→최대 15s).
- 앱 시작 시 pre-flight로 `tmux`/(Dart 런타임 자체는 항상 있으므로 `dart`/`flutter` 체크는 불필요) `bash` 바이너리 존재를 `Process.run('command', ['-v', 'tmux'])`로 확인, 실패 시 대시보드 진입 전 즉시 에러 화면.
### 6.7 tmux Attach — 터미널 에뮬레이터 위젯 연동 (브리프 핵심 요구사항, 타깃별 분기)
Rev.2 §3 전체(suspend 계약, TOCTOU 4분기 표)는 "이 프로세스가 이미 진짜 터미널이다"라는 Textual 특유의 전제 위에 세워졌다. Flutter Desktop/Web은 이 전제가 성립하지 않으므로 **완전히 다른, 더 단순한 계약**으로 대체된다:
| 타깃 | 메커니즘 | 이유 |
| :-- | :--- | :--- |
| **Desktop** | `packages/mam_pty` (`pty_session.dart` 의 FFI PTY 바인딩, 버전 구현 시 FFI 라이브러리 pin)로 `tmux -L <server> attach -t <name>`를 PTY 자식으로 스폰 → 바이트 스트림을 `xterm` 위젯(`Terminal`)에 연결. 키 입력은 위젯 → PTY stdin으로 그대로 전달(`Ctrl-b d` 포함, tmux 클라이언트가 직접 해석). 프로세스 종료(EOF) 시 Attach 패널을 닫고 대시보드로 복귀 | 실제 터미널에서 `tmux attach`했을 때와 **바이트 단위로 동일한 결과**를 보장 — Rev.2가 suspend/resume에서 씨름했던 "raw mode 복원" 문제 자체가 구조적으로 발생하지 않음(Flutter 창은 애초에 raw/cooked mode를 가진 적이 없으므로) |
| **Web** | 브라우저는 프로세스/PTY를 직접 스폰할 수 없음 → `mam_pty_bridge_daemon`(로컬에서 실행되는 순수 Dart 데몬, `packages/mam_pty` 재사용)이 WebSocket으로 동일한 PTY 세션을 노출, `mam_web``xterm` 위젯이 WS 프레임을 바이트 스트림으로 연결 | 브라우저 샌드박스 제약을 우회하지 않으면서, Desktop과 동일한 `pty_session.dart` 코드 경로를 재사용(§6.5 신뢰 경계로 접근 제한) |
| **TUI**(`mam_tui`) | Rev.2 §3.1~§3.4를 **그대로 계승** — 이미 진짜 터미널 안에서 실행 중이므로 별도 위젯 불필요. `Process.start(argv, mode: ProcessStartMode.inheritStdio)`로 포그라운드 실행(Textual의 `suspend()` 대응 없음 — 애초에 mam_tui는 자체 화면 버퍼를 점유하지 않는 단순 라인 기반 프런트엔드이므로 "복원"할 대상이 없다면 그대로 상속, 만약 alternate-screen 기반 렌더링을 채택했다면 Rev.2 3.1의 try/finally 복원 계약을 동일 적용) | 실제 TTY 위에서 도는 것은 Textual과 동일한 상황이므로 그 계약이 그대로 유효 |
**TOCTOU 케이스 표 — Desktop/Web(단순화, Rev.2 §3.4의 4분기 → 2분기로 축소)**:
| 시점 | 처리 |
| :-- | :--- |
| PTY 스폰 **전**(행 선택 후 세션이 이미 죽음) | 스폰 자체를 시도하지 않고 즉시 에러 토스트 + 강제 리프레시 |
| PTY 스폰 **후** tmux attach가 즉시 비정상 종료(TOCTOU 창구에서 사망) 또는 정상 detach | Attach 패널을 닫고 강제 리프레시, exit code에 따라 "session ended" 안내 여부 결정 |
- **중첩 tmux 환경 격리(Rev.2 D4 — 여전히 유효)**: `mam_desktop`/`mam_tui`/`mam_pty_bridge_daemon` 중 무엇이든 자신이 tmux 세션 안에서 실행되는 배포 형태가 가능하므로(에이전트가 이 저장소의 세션 안에서 UI를 띄우는 경우), `pty_session.dart` 스폰 직전에 `Map<String,String>.from(Platform.environment)`에서 `TMUX`/`TMUX_PANE`을 제거한 환경으로 자식을 실행 — 이 로직을 `packages/mam_pty` 한 곳에만 구현해 세 타깃이 공유.
- **리사이즈**: 위젯 크기 변경 콜백에서 `ptySession.resize(cols, rows)`(ioctl `TIOCSWINSZ` 대응) 호출 — Rev.2 §3.3의 SIGWINCH 재동기화 요구사항과 동일 목적.
### 6.8 D8 이식 확인
§3에서 다룬 `status.sh --json` 스키마 확장은 이 섹션의 명령주입/타임아웃/락 계약과 독립적이므로 별도 재론하지 않음 — `stop_purge_service.dart`/`status_repository.dart` 모두 확장된 `sessions_detail`을 그대로 소비.
---
## 7. 화면 상세 설계
- **Sessions 대시보드**: `sessions_detail` 폴링(기본 3초, 설정 가능) → `data_table_2`. 컬럼: `NAME/SERVER/ROLE/YAML/TMUX/CMD/RESUME/JOB_ID/JOB_STATUS/DRIFT`(Rev.1 §4.1과 동일 컬럼 셋, D8 확장으로 실제 데이터 공급 가능해짐). 행 클릭 시 우측 Detail Pane에 `attach_command`/`pane_cwd`/`last_visible_status` 표시(§6.5의 sanitize 규칙 적용).
- **Attach 패널**: 행의 "Attach" 버튼 → 우측 슬라이드 패널 또는 별도 창에 `xterm` 위젯 오픈(§6.7). 닫기(`Ctrl-b d` 또는 패널의 X 버튼)로 대시보드 복귀.
- **Stop 다이얼로그**: reason 텍스트 필드(기본 `manual_stop`) → 확인 → `stopPurgeService.stop(name, reason)`. 이미 `stopped`인 행은 버튼 비활성화.
- **Purge 다이얼로그**: §6.4.
- **Skills 탭**: `.agents/skills/*/SKILL.md` 스캔 → `ListView.builder`, 태그/이름 필터. 상세는 `flutter_markdown`으로 원문 그대로 렌더링(읽기 전용, 편집 기능 없음 — Rev.1 원칙 계승).
- **New/Resume 마법사**: `Stepper` 기반 폼 → `create_session.sh`/`resolve_session_id.sh`+`update_yaml_resumed.sh` argv 매핑.
- **Jobs 탭**: `delegate-job` 목록 폴링, Sessions의 `JOB_ID`와 상호 하이라이트.
---
## 8. 안전장치 요약표 (Rev.1 §5 + Rev.2 + D8 통합)
| 위험 | 완화책 |
| :--- | :--- |
| 실수로 stop | reason 입력 다이얼로그 + 이미 stopped인 행 버튼 비활성화 |
| 실수로 대화 영구 삭제(purge) | 2단계 확인 + 완전 일치 재입력 + 워크스페이스 경로 노출 + TOCTOU 재검증(§6.4) |
| exit 4(purge aborted)를 성공으로 오인 | exit-code 매핑표(§6.3) 강제, 영구 경고 배지 |
| 명령 주입 | `command_runner.dart`가 유일한 실행 지점, argv list 강제, `runInShell` 전역 금지(§6.5) |
| UI가 YAML/SQLite/jsonl 직접 조작 | `mam_core`/`mam_pty` 어디에도 쓰기/삭제 코드 없음 — 모든 변경은 서브프로세스 위임 |
| `status.sh --json` 스키마 확장이 기존 소비자를 깨뜨림 | 기존 키 무변경 + 신규 키만 추가, byte-identical 텍스트모드 회귀 테스트(§3.1, §10) |
| 중첩 tmux 환경 오염 | `packages/mam_pty`에서 스폰 직전 `TMUX`/`TMUX_PANE` 제거(§6.7) |
| Web 타깃의 원격 파괴적 조작 노출 | 로컬 전용 바인딩 + 랜덤 토큰 + 기본 뮤테이션 비활성(§5.5) |
| 폴링 실패로 UI 크래시/오작동 | stale 배너 + 백오프, 마지막 성공 스냅샷 유지(§6.6) |
| 동시 다중 UI 인스턴스의 상충 명령 | SKILL.md Pitfalls에 "동일 워크스페이스 다중 기동 금지" 명문화(Rev.1 원칙 계승) |
---
## 9. 마일스톤 및 Task Checklist
- [ ] **M0 — 스캐폴딩 & 데이터 계층**: `melos` 모노레포 초기화, `packages/mam_core`(모델/`command_runner`/`status_repository`), **`status.sh --json`의 D8 스키마 확장(§3.1) 구현 및 회귀 검증**, pre-flight 체크. **§5.4의 "TUI 정직한 스코프" 결정에 대한 명시적 확인**(구현 착수 전 필수 게이트).
- [ ] **M1 — 읽기 전용 대시보드(Desktop)**: `sessions_detail` 폴링 + `data_table_2` + Detail Pane (변경 기능 없음).
- [ ] **M2 — Attach 실행(Desktop)**: `packages/mam_pty` (`pty_session.dart`) + `xterm` 위젯 연동(§6.7), TOCTOU 2분기 처리, env 격리, 리사이즈.
- [ ] **M3 — Stop 액션**: exit-code 매핑(§6.3) + 60초 타임아웃 + 행 단위 락(§6.2).
- [ ] **M4 — Purge 액션**: 2단계 확인 + TOCTOU 재검증 + exit 4 영구 배지 + no-timeout/≥120s·no-kill 정책(§6.1).
- [ ] **M5 — Skills 탭**: front-matter 파싱 + 필터 + Markdown 뷰어.
- [ ] **M6 — New/Resume 마법사**: `create_session.sh`/`resolve_session_id.sh`+`update_yaml_resumed.sh` 연동.
- [ ] **M7 — Jobs 탭**: `delegate-job` 목록/상태 조회, Sessions와 상호 참조.
- [ ] **M8 — Web 포팅**: `mam_web` + `mam_pty_bridge_daemon`(§6.7 Web 행) + §5.5 신뢰 경계 하드게이트(로컬 바인딩, 토큰, 기본 뮤테이션 비활성) 구현 및 검증.
- [ ] **M9 — TUI 포팅**: `apps/mam_tui`(순수 Dart, `mam_core` 재사용) + Rev.2 §3.1~§3.4 suspend/exec 계약 적용(§6.7 TUI 행) + §6.5 예외(ANSI sanitize 필요) 반영.
- [ ] **M10 — 스킬 문서화**: `.agents/skills/multi-agent-mux-ui/SKILL.md` 작성(`related_skills`에 status/stop/create/resume/delegate-job 전부 명시), `pubspec.yaml`/`melos.yaml`에 의존성 확정 버전 기록.
---
## 10. 검증(DoD) 절차 — Creator가 구현 완료 후 반드시 실행
1. **`status.sh` 회귀 없음(§3.1)**:
```bash
diff <(bash status.sh) <(git show HEAD~1:.../status.sh | bash /dev/stdin) # 개념적 예시 — 실제로는
# 변경 전/후 커밋 각각에서 인자 없는 텍스트 모드 출력이 byte-identical 한지 확인
jq 'del(.sessions_detail)' <(bash status.sh --json) # 기존 5개 키만 추출해 이전 스키마와 diff
```
2. **비파괴 검증**: `mam_core`/`mam_pty` 전체에서 파일 쓰기/삭제 API 직접 호출이 없는지:
```bash
grep -rn "\.writeAsString\|\.writeAsBytes\|\.delete(\|openWrite(" packages/mam_core/lib packages/mam_pty/lib \
|| echo "OK: no direct state/file mutation in UI/PTY layer"
```
3. **명령 주입 방어**:
```bash
grep -rn "runInShell: *true" packages/ apps/ && echo "FAIL: runInShell true found" || echo "OK"
```
4. **행 단위 락 검증**: 한 행에서 Stop 실행 중에도 다른 행의 폴링(TMUX/DRIFT 컬럼)이 3초 이상 지연 없이 계속 갱신되는지 육안 확인.
5. **exit 4 시나리오**: `stop_session.sh`를 kill 체인이 못 잡도록 인위 재현(또는 CLI 직접 실행으로 exit 4 확인) 후, UI에서 해당 행이 삭제되지 않고 경고 배지가 남는지 확인.
6. **Attach 무결성(Desktop/Web)**: 세션 선택 → Attach → 실제 tmux attach 화면이 `xterm` 위젯에 그대로 렌더링되는지 → `Ctrl-b d`로 detach → 패널이 정상 종료되고 대시보드로 복귀하는지 확인. 강제로 다른 터미널에서 `tmux kill-session`하여 비정상 종료 케이스도 재현.
7. **중첩 tmux env 검증**: UI 프로세스를 tmux 세션 내부에서 실행한 뒤 Attach → detach 후 `$TMUX`가 UI 자신의 것으로 정상 복귀하는지, PTY 자식 프로세스의 env 덤프에 부모 `$TMUX`가 새어 들어가지 않았는지 확인.
8. **Web 신뢰 경계**: 토큰 없이 `mam_pty_bridge_daemon`에 WS 연결 시도 → 거부되는지, `mam_web`에서 브릿지 미연결 상태로 purge/stop 버튼이 비활성 상태인지 확인.
9. **정적 분석**: `melos exec -- flutter analyze` / `melos exec -- dart format --set-exit-if-changed .` 전체 clean.
10. **skills 탭 정확성**: `find .agents/skills -maxdepth 2 -name SKILL.md | wc -l`과 UI 목록 항목 수 일치.
11. **회귀 없음**: `status.sh` 외 어떤 기존 셸 스크립트도 이번 작업으로 수정되지 않았는지 `git diff --stat` 확인.
12. 리뷰어 세션(Planner-Reviewer, 본 세션)에 `git diff` 전달 → `report-final.md` 마지막 단독 행에 `[VERDICT: PASS]` 획득해야 완결.
---
## 11. 리스크 및 향후 과제 (Out of Scope)
- **`xterm`/PTY 바인딩 패키지의 정확한 이름·버전**은 생태계 변동 가능성이 있어 구현 착수 시점에 재조사 후 pin (Rev.2가 Textual suspend API 버전 재확인을 요구했던 것과 동일한 이유).
- **tmux control-mode(`-CC`) 기반 read-only 미러링**: Detail Pane에서 전체 Attach 없이 pane 내용을 실시간 미리보기하는 기능은 이 계획의 스코프 밖(§6.7의 raw-PTY 방식보다 구현 비용이 높음) — 향후 과제 후보로만 기록.
- **다중 tmux 서버 동시 표시**, **원격 다중 사용자 인증 계층을 갖춘 웹 배포**는 Rev.2 §9의 원 논의를 그대로 계승 — 본 계획은 로컬 단일 운영자 전제(§5.5)를 유지.
---
## 12. 완료 요약
본 Rev.3는 Rev.1(Python+Textual)·Rev.2(예외처리/attach 무결성/purge 보안, 4라운드 검증 후 PASS 확정)를 **Flutter(Desktop/Web/TUI)** 로 전면 재작성했다. 기존 셸 스크립트 wrap 원칙, exit-code 계약(D1~D3), 명령주입 방지(D5), 폴링 방어(D6), purge 보안 계약(§6.4)은 언어 무관 불변 계약으로 그대로 이식했고, attach 무결성 계약(D4/D7)은 Desktop/Web에서는 임베디드 PTY+`xterm` 위젯 방식으로 대체(구조적으로 더 단순해짐), TUI에서만 Rev.2의 suspend/exec 계약을 그대로 유지하도록 타깃별로 분리했다. 또한 재작성 과정에서 4라운드 리뷰 모두가 놓쳤던 `status.sh --json`의 데이터 스키마 갭(D8)을 발견해, 순수 additive 확장으로 해결하는 설계를 제시했다. M0~M10 마일스톤(§9)과 DoD(§10)를 따르면 Creator가 추가 설계 논의 없이 구현에 착수할 수 있다.
[VERDICT: PASS]