Files
multi-agent-mux/.agents/reports/canary-projects-multi-agent-mux-planner-reviewer-claude/report-final.md
T

38 KiB

📑 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 --agent <claude agy
세션 재개 .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)로 추가한다.

// 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 StreamProviderdata_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_tuiFlutter 렌더링이 아닌 순수 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_daemon127.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

// 의사코드 — 정확한 시그니처는 구현 시 고정
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> argvProcess.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.dartstatus.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_webxterm 위젯이 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):
    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 직접 호출이 없는지:
    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. 명령 주입 방어:
    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 계약(D1D3), 명령주입 방지(D5), 폴링 방어(D6), purge 보안 계약(§6.4)은 언어 무관 불변 계약으로 그대로 이식했고, attach 무결성 계약(D4/D7)은 Desktop/Web에서는 임베디드 PTY+xterm 위젯 방식으로 대체(구조적으로 더 단순해짐), TUI에서만 Rev.2의 suspend/exec 계약을 그대로 유지하도록 타깃별로 분리했다. 또한 재작성 과정에서 4라운드 리뷰 모두가 놓쳤던 status.sh --json의 데이터 스키마 갭(D8)을 발견해, 순수 additive 확장으로 해결하는 설계를 제시했다. M0M10 마일스톤(§9)과 DoD(§10)를 따르면 Creator가 추가 설계 논의 없이 구현에 착수할 수 있다.

[VERDICT: PASS]