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]
@@ -19,15 +19,113 @@ JSON=0
# read-only drift snapshot — reconcile.sh --dry-run (no side effects)
DRIFT_JSON="$(bash "$RECONCILE" --once --emit-diff --dry-run)"
if [ "$JSON" = "1" ]; then
printf '%s\n' "$DRIFT_JSON"
exit 0
fi
# Project root (parent of .agents/) holds the multi-agent-mux-delegate-job .mam registry.
# Resolved relative to this script — no hardcoded absolute path (review item 6).
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../" && pwd)"
if [ "$JSON" = "1" ]; then
# D8: --json historically only carried reconcile.sh's drift subset (timestamp/
# yaml_path/tmux_sessions_alive/tmux_confirmed/drifts/actions), not the enriched
# per-row fields (RESUME/JOB_ID/JOB_STATUS/CMD/attach_command/pane.cwd/...) that
# only this script's text-mode block computed. Fixed additively below via a
# 'sessions_detail' key — every existing key is passed through untouched, so
# any consumer of the pre-fix schema keeps working unmodified.
MAM_STATE_JSON="$(load_state_json)" DRIFT_JSON="$DRIFT_JSON" env_python "$AGENT_SESSIONS_YAML" PROJECT_ROOT="$PROJECT_ROOT" <<'PYEOF'
import os, json, glob
home = os.environ['HOME_DIR']
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
drift = json.loads(os.environ['DRIFT_JSON'])
try:
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
except Exception:
d = {}
alive = set(drift.get('tmux_sessions_alive', []))
drift_by_name = {}
for dr in drift.get('drifts', []):
drift_by_name.setdefault(dr['name'], []).append(dr['class'])
def resume_on_disk(s):
# workspace-SCOPED check only — per-row own id, never a global identity (P0-C)
name = s.get('name', '')
cwd = (s.get('pane') or {}).get('cwd', '')
if name.endswith('-creator-claude'):
u = s.get('claude_session_id_own')
if u:
key = cwd.replace('/', '-').replace('_', '-')
return 'yes' if os.path.exists(f"{claude_project_dir}/{key}/{u}.jsonl") else 'MISSING'
key = cwd.replace('/', '-').replace('_', '-')
return 'scan' if glob.glob(f"{claude_project_dir}/{key}/*.jsonl") else 'no'
if name.endswith('-creator-agy'):
u = s.get('agy_conversation_id_own')
if u:
return 'yes' if os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{u}.db") else 'MISSING'
return 'no'
return '?'
def get_job_status(s):
jid = s.get('delegate_job_id')
if not jid:
return ('-', '-')
project_root = os.environ.get('PROJECT_ROOT', '.')
candidates = [
os.path.join('.mam', 'jobs', f"{jid}.json"),
os.path.join(project_root, '.mam', 'jobs', f"{jid}.json"),
os.path.join(project_root, '.mam', 'delegate_job_logs', jid, 'status.json'),
]
for path in candidates:
if os.path.exists(path):
try:
with open(path) as jf:
job_data = json.load(jf)
return (jid, job_data.get('status', 'unknown'))
except Exception:
pass
return (jid, 'unknown')
sessions_detail = []
for s in d.get('tmux_sessions', []):
name = s.get('name', '?')
server = s.get('tmux_server') or 'default'
jid, jstatus = get_job_status(s)
pane = s.get('pane') or {}
sessions_detail.append({
# Fields named/typed to match the reviewed D8 contract
# (.mam/jobs/40bdce88/claude-reports/report-final.md §3.1) exactly —
# mam_core maps this straight onto its Session/Pane/Drift models.
'name': name,
'server': server,
'status': s.get('status', '?'),
'tmux_alive': f"{name}|{server}" in alive,
'cmd': pane.get('cmd'),
'role': s.get('role'),
'resume_state': resume_on_disk(s),
'job_id': jid,
'job_status': jstatus,
'pane_cwd': pane.get('cwd'),
'attach_command': s.get('attach_command'),
'drift_classes': drift_by_name.get(name, []),
# Additive beyond the D8 example — needed by the M1 Detail Pane
# (pane pid / full launch command / start command / last banner text).
'pane_pid': pane.get('pid'),
'cmd_full': pane.get('cmd_full'),
'start_command': s.get('start_command'),
'last_visible_status': s.get('last_visible_status'),
})
drift['sessions_detail'] = sessions_detail
print(json.dumps(drift, ensure_ascii=False))
PYEOF
exit 0
fi
MAM_STATE_JSON="$(load_state_json)" DRIFT_JSON="$DRIFT_JSON" env_python "$AGENT_SESSIONS_YAML" PROJECT_ROOT="$PROJECT_ROOT" <<'PYEOF'
import os, sys, json, glob
@@ -1,121 +1,155 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mam_core/mam_core.dart';
import 'src/providers/session_providers.dart';
import 'src/theme/app_theme.dart';
import 'src/widgets/detail_pane.dart';
import 'src/widgets/session_table.dart';
import 'src/widgets/stale_banner.dart';
void main() {
runApp(const MyApp());
runApp(const ProviderScope(child: MamDesktopApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
class MamDesktopApp extends StatelessWidget {
const MamDesktopApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
title: 'multi-agent-mux',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(),
home: const DashboardScreen(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
class DashboardScreen extends ConsumerWidget {
const DashboardScreen({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
Widget build(BuildContext context, WidgetRef ref) {
final pollAsync = ref.watch(sessionsPollProvider);
return Scaffold(
body: DecoratedBox(
decoration: const BoxDecoration(gradient: backgroundGradient),
child: SafeArea(
child: pollAsync.when(
data: (poll) => _DashboardBody(poll: poll),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => _ErrorScreen(error: error),
),
),
),
);
}
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
class _ErrorScreen extends StatelessWidget {
final Object error;
const _ErrorScreen({required this.error});
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: .center,
mainAxisSize: MainAxisSize.min,
children: [
const Text('You have pushed the button this many times:'),
const Icon(Icons.error_outline, color: AppColors.danger, size: 40),
const SizedBox(height: 12),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
'Failed to start status polling:\n$error',
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.danger),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
);
}
}
class _DashboardBody extends ConsumerWidget {
final SessionsPoll poll;
const _DashboardBody({required this.poll});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedName = ref.watch(selectedSessionNameProvider);
final sessions = poll.snapshot?.sessions ?? const <SessionRow>[];
final matches = sessions.where((s) => s.name == selectedName);
final selectedRow = matches.isEmpty ? null : matches.first;
return Column(
children: [
_TopBar(poll: poll),
StaleBanner(poll: poll),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.all(16),
child: SessionTable(
sessions: sessions,
selectedName: selectedName,
onSelect: (name) =>
ref.read(selectedSessionNameProvider.notifier).state =
name,
),
),
),
SizedBox(width: 380, child: DetailPane(session: selectedRow)),
],
),
),
],
);
}
}
class _TopBar extends StatelessWidget {
final SessionsPoll poll;
const _TopBar({required this.poll});
@override
Widget build(BuildContext context) {
final count = poll.snapshot?.sessions.length ?? 0;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: Row(
children: [
const Text(
'multi-agent-mux',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.accent.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'$count session${count == 1 ? '' : 's'}',
style: const TextStyle(
color: AppColors.accent2,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
@@ -0,0 +1,26 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mam_core/mam_core.dart';
import '../status_script_locator.dart';
/// The single [SessionService] instance for this app run — owns the
/// resolved path to `status.sh`.
final sessionServiceProvider = Provider<SessionService>((ref) {
return SessionService(statusScriptPath: locateStatusScript());
});
/// Polling + stale/backoff (D6) sits in `mam_core`; this provider just wires
/// it up. `apps/mam_desktop` does not reimplement the timer/backoff logic.
final statusRepositoryProvider = Provider<StatusRepository>((ref) {
return StatusRepository(sessionService: ref.watch(sessionServiceProvider));
});
/// Reactive stream of [SessionsPoll] the UI subscribes to. 4s base interval
/// on success, backing off to 6s/15s on repeated `status.sh` failures.
final sessionsPollProvider = StreamProvider<SessionsPoll>((ref) {
final repo = ref.watch(statusRepositoryProvider);
return repo.watch();
});
/// Currently selected row name for the Master-Detail layout.
final selectedSessionNameProvider = StateProvider<String?>((ref) => null);
@@ -0,0 +1,26 @@
import 'dart:io';
/// Locates `status.sh` by walking up from the current working directory to
/// the repo root (marked by `.git`), then descending a fixed path — robust
/// regardless of which subdirectory `flutter run` happens to be invoked
/// from, unlike a hardcoded relative path.
String locateStatusScript() {
var dir = Directory.current;
while (!Directory('${dir.path}/.git').existsSync()) {
final parent = dir.parent;
if (parent.path == dir.path) {
throw StateError(
'Could not locate the repo root (.git) from ${Directory.current.path}. '
'Run mam_desktop from within the multi-agent-mux repo checkout.',
);
}
dir = parent;
}
final scriptPath =
'${dir.path}/.agents/skills/multi-agent-mux-status/scripts/status.sh';
if (!File(scriptPath).existsSync()) {
throw StateError('status.sh not found at $scriptPath');
}
return scriptPath;
}
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
class AppColors {
AppColors._();
static const bgTop = Color(0xFF0B0F1A);
static const bgBottom = Color(0xFF141B2E);
static const accent = Color(0xFF7C5CFF);
static const accent2 = Color(0xFF19C6E0);
static const surface = Color(0xFF171E31);
static const surfaceAlt = Color(0xFF1E2740);
static const border = Color(0xFF2A3350);
static const textPrimary = Color(0xFFEAF0FF);
static const textSecondary = Color(0xFF93A0C4);
static const danger = Color(0xFFFF5C7A);
static const warning = Color(0xFFFFB454);
static const success = Color(0xFF4CE0B3);
}
const backgroundGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.bgTop, AppColors.bgBottom],
);
ThemeData buildAppTheme() {
final base = ThemeData(brightness: Brightness.dark, useMaterial3: true);
return base.copyWith(
scaffoldBackgroundColor: AppColors.bgTop,
colorScheme: base.colorScheme.copyWith(
primary: AppColors.accent,
secondary: AppColors.accent2,
surface: AppColors.surface,
error: AppColors.danger,
),
textTheme: base.textTheme.apply(
bodyColor: AppColors.textPrimary,
displayColor: AppColors.textPrimary,
),
dividerColor: AppColors.border,
cardTheme: CardThemeData(
color: AppColors.surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: AppColors.border),
),
),
);
}
@@ -0,0 +1,263 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mam_core/mam_core.dart';
import '../theme/app_theme.dart';
class _KV {
final String label;
final String value;
final bool copyable;
const _KV(this.label, this.value, {this.copyable = false});
}
class DetailPane extends StatelessWidget {
final SessionRow? session;
const DetailPane({super.key, required this.session});
@override
Widget build(BuildContext context) {
final s = session;
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.surface, AppColors.surfaceAlt],
),
border: Border(left: BorderSide(color: AppColors.border)),
),
child: s == null ? const _EmptyDetail() : _DetailContent(session: s),
);
}
}
class _EmptyDetail extends StatelessWidget {
const _EmptyDetail();
@override
Widget build(BuildContext context) {
return const Center(
child: Text(
'Select a session to see details',
style: TextStyle(color: AppColors.textSecondary),
),
);
}
}
class _DetailContent extends StatelessWidget {
final SessionRow session;
const _DetailContent({required this.session});
@override
Widget build(BuildContext context) {
final s = session;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Header(session: s),
const SizedBox(height: 24),
_Section(
title: 'PANE',
entries: [
_KV('pid', s.pane.pid?.toString() ?? '?'),
_KV('cwd', s.pane.cwd ?? '?'),
_KV('cmd', s.pane.cmd ?? '?'),
_KV('cmd_full', s.pane.cmdFull ?? '?', copyable: true),
],
),
const SizedBox(height: 16),
_Section(
title: 'ATTACH',
entries: [
_KV('attach_command', s.attachCommand ?? '?', copyable: true),
_KV('start_command', s.startCommand ?? '?', copyable: true),
],
),
const SizedBox(height: 16),
_Section(
title: 'STATUS',
entries: [
_KV('last_visible_status', s.lastVisibleStatus ?? '?'),
_KV('resume_state', s.resumeState),
_KV('job_id', s.jobId),
_KV('job_status', s.jobStatus),
_KV(
'drift_classes',
s.driftClasses.isEmpty ? '-' : s.driftClasses.join(', '),
),
],
),
],
),
);
}
}
class _Header extends StatelessWidget {
final SessionRow session;
const _Header({required this.session});
@override
Widget build(BuildContext context) {
final s = session;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.accent, AppColors.accent2],
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: AppColors.accent.withValues(alpha: 0.35),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
s.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_Pill(text: s.status.toUpperCase()),
_Pill(text: s.tmuxAlive ? 'TMUX ALIVE' : 'TMUX DEAD'),
if (s.role != null) _Pill(text: s.role!.toUpperCase()),
_Pill(text: 'SERVER: ${s.server}'),
],
),
],
),
);
}
}
class _Pill extends StatelessWidget {
final String text;
const _Pill({required this.text});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(999),
),
child: Text(
text,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
);
}
}
class _Section extends StatelessWidget {
final String title;
final List<_KV> entries;
const _Section({required this.title, required this.entries});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
const SizedBox(height: 12),
...entries.map((e) => _KVRow(entry: e)),
],
),
);
}
}
class _KVRow extends StatelessWidget {
final _KV entry;
const _KVRow({required this.entry});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 130,
child: Text(
entry.label,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
),
),
),
Expanded(
child: SelectableText(
entry.value,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 13,
fontFamily: 'monospace',
),
),
),
if (entry.copyable)
SizedBox(
width: 32,
height: 32,
child: IconButton(
padding: EdgeInsets.zero,
icon: const Icon(
Icons.copy,
size: 15,
color: AppColors.textSecondary,
),
tooltip: 'Copy',
onPressed: () =>
Clipboard.setData(ClipboardData(text: entry.value)),
),
),
],
),
);
}
}
@@ -0,0 +1,167 @@
import 'package:data_table_2/data_table_2.dart';
import 'package:flutter/material.dart';
import 'package:mam_core/mam_core.dart';
import '../theme/app_theme.dart';
class SessionTable extends StatelessWidget {
final List<SessionRow> sessions;
final String? selectedName;
final ValueChanged<String> onSelect;
const SessionTable({
super.key,
required this.sessions,
required this.selectedName,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
clipBehavior: Clip.antiAlias,
child: DataTable2(
columnSpacing: 16,
horizontalMargin: 16,
minWidth: 900,
headingRowColor: WidgetStateProperty.all(AppColors.surfaceAlt),
headingTextStyle: const TextStyle(
color: AppColors.textSecondary,
fontWeight: FontWeight.w700,
fontSize: 12,
letterSpacing: 0.6,
),
dataRowHeight: 44,
empty: const Center(
child: Text(
'(no sessions registered)',
style: TextStyle(color: AppColors.textSecondary),
),
),
columns: const [
DataColumn2(label: Text('NAME'), size: ColumnSize.L),
DataColumn2(label: Text('SERVER'), size: ColumnSize.S),
DataColumn2(label: Text('YAML'), size: ColumnSize.S),
DataColumn2(label: Text('TMUX'), size: ColumnSize.S),
DataColumn2(label: Text('CMD'), size: ColumnSize.S),
DataColumn2(label: Text('RESUME'), size: ColumnSize.S),
DataColumn2(label: Text('JOB_ID'), size: ColumnSize.S),
DataColumn2(label: Text('JOB_STATUS'), size: ColumnSize.S),
DataColumn2(label: Text('DRIFT'), size: ColumnSize.S),
],
rows: sessions.map((s) {
final selected = s.name == selectedName;
return DataRow2(
selected: selected,
color: selected
? WidgetStateProperty.all(
AppColors.accent.withValues(alpha: 0.16),
)
: null,
onTap: () => onSelect(s.name),
cells: [
DataCell(
Text(
s.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.textPrimary),
),
),
DataCell(
Text(
s.server,
style: const TextStyle(color: AppColors.textSecondary),
),
),
DataCell(_StatusChip(status: s.status)),
DataCell(_TmuxChip(alive: s.tmuxAlive)),
DataCell(
Text(
s.pane.cmd ?? '?',
style: const TextStyle(color: AppColors.textPrimary),
),
),
DataCell(
Text(
s.resumeState,
style: const TextStyle(color: AppColors.textSecondary),
),
),
DataCell(
Text(
s.jobId,
style: const TextStyle(color: AppColors.textSecondary),
),
),
DataCell(
Text(
s.jobStatus,
style: const TextStyle(color: AppColors.textSecondary),
),
),
DataCell(
s.hasDrift
? Text(
s.driftClasses.join(','),
style: const TextStyle(
color: AppColors.warning,
fontWeight: FontWeight.w600,
),
)
: const Text(
'-',
style: TextStyle(color: AppColors.textSecondary),
),
),
],
);
}).toList(),
),
);
}
}
class _StatusChip extends StatelessWidget {
final String status;
const _StatusChip({required this.status});
@override
Widget build(BuildContext context) {
Color color;
switch (status) {
case 'running':
color = AppColors.success;
case 'stopped':
case 'terminated':
case 'archived':
color = AppColors.textSecondary;
default:
color = AppColors.warning;
}
return Text(
status,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
);
}
}
class _TmuxChip extends StatelessWidget {
final bool alive;
const _TmuxChip({required this.alive});
@override
Widget build(BuildContext context) {
return Text(
alive ? 'alive' : 'dead',
style: TextStyle(
color: alive ? AppColors.success : AppColors.danger,
fontWeight: FontWeight.w600,
),
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:mam_core/mam_core.dart';
import '../theme/app_theme.dart';
/// D6: when `status.sh` polling fails, the last good snapshot is kept and
/// this banner surfaces staleness instead of the dashboard silently
/// blanking out or crashing.
class StaleBanner extends StatelessWidget {
final SessionsPoll poll;
const StaleBanner({super.key, required this.poll});
@override
Widget build(BuildContext context) {
if (!poll.stale) return const SizedBox.shrink();
final lastOk = poll.lastOkAt;
final lastOkText = lastOk == null
? '?'
: '${lastOk.hour.toString().padLeft(2, '0')}:'
'${lastOk.minute.toString().padLeft(2, '0')}:'
'${lastOk.second.toString().padLeft(2, '0')}';
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: AppColors.warning.withValues(alpha: 0.15),
child: Row(
children: [
const Icon(
Icons.warning_amber_rounded,
color: AppColors.warning,
size: 18,
),
const SizedBox(width: 8),
Text(
'⚠ status snapshot stale (last ok: $lastOkText)',
style: const TextStyle(
color: AppColors.warning,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
@@ -1,30 +1,74 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mam_core/mam_core.dart';
import 'package:mam_desktop/main.dart';
import 'package:mam_desktop/src/providers/session_providers.dart';
import 'package:mam_desktop/src/theme/app_theme.dart';
import 'package:mam_desktop/src/widgets/detail_pane.dart';
import 'package:mam_desktop/src/widgets/session_table.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
testWidgets('SessionTable renders rows and drives selection', (tester) async {
final rows = [
SessionRow(
name: 'demo-session',
server: 'default',
status: 'running',
tmuxAlive: true,
pane: const Pane(
pid: 123,
cwd: '/x',
cmd: 'claude',
cmdFull: 'claude --foo',
),
role: 'creator',
resumeState: 'yes',
jobId: '-',
jobStatus: '-',
attachCommand: 'tmux attach -t demo-session',
driftClasses: const ['B'],
),
];
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
String? selected;
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: SessionTable(
sessions: rows,
selectedName: null,
onSelect: (name) => selected = name,
),
),
),
);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
expect(find.text('demo-session'), findsOneWidget);
expect(find.text('running'), findsOneWidget);
await tester.tap(find.text('demo-session'));
await tester.pump();
expect(selected, 'demo-session');
});
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
testWidgets('DetailPane shows placeholder when nothing selected', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: const Scaffold(body: DetailPane(session: null)),
),
);
expect(find.text('Select a session to see details'), findsOneWidget);
});
test('selectedSessionNameProvider defaults to null', () {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(selectedSessionNameProvider), isNull);
});
}
@@ -15,91 +15,6 @@
"test"
]
},
{
"name": "yaml",
"version": "3.1.3",
"dependencies": [
"collection",
"source_span",
"string_scanner"
]
},
{
"name": "string_scanner",
"version": "1.4.1",
"dependencies": [
"source_span"
]
},
{
"name": "source_span",
"version": "1.10.2",
"dependencies": [
"collection",
"path",
"term_glyph"
]
},
{
"name": "term_glyph",
"version": "1.2.2",
"dependencies": []
},
{
"name": "path",
"version": "1.9.1",
"dependencies": []
},
{
"name": "collection",
"version": "1.19.1",
"dependencies": []
},
{
"name": "http",
"version": "1.6.0",
"dependencies": [
"async",
"http_parser",
"meta",
"web"
]
},
{
"name": "web",
"version": "1.1.1",
"dependencies": []
},
{
"name": "http_parser",
"version": "4.1.2",
"dependencies": [
"collection",
"source_span",
"string_scanner",
"typed_data"
]
},
{
"name": "typed_data",
"version": "1.4.0",
"dependencies": [
"collection"
]
},
{
"name": "async",
"version": "2.13.1",
"dependencies": [
"collection",
"meta"
]
},
{
"name": "meta",
"version": "1.19.0",
"dependencies": []
},
{
"name": "test",
"version": "1.31.2",
@@ -131,6 +46,55 @@
"yaml"
]
},
{
"name": "meta",
"version": "1.19.0",
"dependencies": []
},
{
"name": "http",
"version": "1.6.0",
"dependencies": [
"async",
"http_parser",
"meta",
"web"
]
},
{
"name": "yaml",
"version": "3.1.3",
"dependencies": [
"collection",
"source_span",
"string_scanner"
]
},
{
"name": "webkit_inspection_protocol",
"version": "1.2.1",
"dependencies": [
"logging"
]
},
{
"name": "web_socket_channel",
"version": "3.0.3",
"dependencies": [
"async",
"crypto",
"stream_channel",
"web",
"web_socket"
]
},
{
"name": "typed_data",
"version": "1.4.0",
"dependencies": [
"collection"
]
},
{
"name": "test_core",
"version": "0.6.19",
@@ -173,6 +137,122 @@
"term_glyph"
]
},
{
"name": "stream_channel",
"version": "2.1.4",
"dependencies": [
"async"
]
},
{
"name": "stack_trace",
"version": "1.12.1",
"dependencies": [
"path"
]
},
{
"name": "source_span",
"version": "1.10.2",
"dependencies": [
"collection",
"path",
"term_glyph"
]
},
{
"name": "shelf_web_socket",
"version": "3.0.0",
"dependencies": [
"shelf",
"stream_channel",
"web_socket_channel"
]
},
{
"name": "shelf_static",
"version": "1.1.3",
"dependencies": [
"convert",
"http_parser",
"mime",
"path",
"shelf"
]
},
{
"name": "shelf_packages_handler",
"version": "3.0.2",
"dependencies": [
"path",
"shelf",
"shelf_static"
]
},
{
"name": "shelf",
"version": "1.4.2",
"dependencies": [
"async",
"collection",
"http_parser",
"path",
"stack_trace",
"stream_channel"
]
},
{
"name": "pool",
"version": "1.5.2",
"dependencies": [
"async",
"stack_trace"
]
},
{
"name": "path",
"version": "1.9.1",
"dependencies": []
},
{
"name": "package_config",
"version": "3.0.0",
"dependencies": [
"meta"
]
},
{
"name": "node_preamble",
"version": "2.0.2",
"dependencies": []
},
{
"name": "matcher",
"version": "0.12.20",
"dependencies": [
"async",
"meta",
"stack_trace",
"term_glyph",
"test_api"
]
},
{
"name": "io",
"version": "1.0.5",
"dependencies": [
"meta",
"path",
"string_scanner"
]
},
{
"name": "http_multi_server",
"version": "3.2.2",
"dependencies": [
"async"
]
},
{
"name": "coverage",
"version": "1.15.1",
@@ -191,13 +271,106 @@
]
},
{
"name": "cli_config",
"version": "0.2.0",
"name": "collection",
"version": "1.19.1",
"dependencies": []
},
{
"name": "boolean_selector",
"version": "2.1.2",
"dependencies": [
"args",
"source_span",
"string_scanner"
]
},
{
"name": "async",
"version": "2.13.1",
"dependencies": [
"collection",
"meta"
]
},
{
"name": "analyzer",
"version": "14.1.0",
"dependencies": [
"_fe_analyzer_shared",
"collection",
"convert",
"crypto",
"glob",
"meta",
"package_config",
"path",
"pub_semver",
"source_span",
"watcher",
"yaml"
]
},
{
"name": "web",
"version": "1.1.1",
"dependencies": []
},
{
"name": "http_parser",
"version": "4.1.2",
"dependencies": [
"collection",
"source_span",
"string_scanner",
"typed_data"
]
},
{
"name": "string_scanner",
"version": "1.4.1",
"dependencies": [
"source_span"
]
},
{
"name": "logging",
"version": "1.3.0",
"dependencies": []
},
{
"name": "web_socket",
"version": "1.0.1",
"dependencies": [
"web"
]
},
{
"name": "crypto",
"version": "3.0.7",
"dependencies": [
"typed_data"
]
},
{
"name": "vm_service",
"version": "15.2.0",
"dependencies": []
},
{
"name": "source_maps",
"version": "0.10.13",
"dependencies": [
"source_span"
]
},
{
"name": "source_map_stack_trace",
"version": "2.1.2",
"dependencies": [
"path",
"source_maps",
"stack_trace"
]
},
{
"name": "glob",
"version": "2.1.3",
@@ -218,160 +391,18 @@
]
},
{
"name": "shelf_packages_handler",
"version": "3.0.2",
"dependencies": [
"path",
"shelf",
"shelf_static"
]
},
{
"name": "pool",
"version": "1.5.2",
"dependencies": [
"async",
"stack_trace"
]
},
{
"name": "node_preamble",
"version": "2.0.2",
"name": "args",
"version": "2.7.0",
"dependencies": []
},
{
"name": "boolean_selector",
"version": "2.1.2",
"dependencies": [
"source_span",
"string_scanner"
]
"name": "term_glyph",
"version": "1.2.2",
"dependencies": []
},
{
"name": "source_map_stack_trace",
"version": "2.1.2",
"dependencies": [
"path",
"source_maps",
"stack_trace"
]
},
{
"name": "source_maps",
"version": "0.10.13",
"dependencies": [
"source_span"
]
},
{
"name": "file",
"version": "7.0.1",
"dependencies": [
"meta",
"path"
]
},
{
"name": "webkit_inspection_protocol",
"version": "1.2.1",
"dependencies": [
"logging"
]
},
{
"name": "stream_channel",
"version": "2.1.4",
"dependencies": [
"async"
]
},
{
"name": "stack_trace",
"version": "1.12.1",
"dependencies": [
"path"
]
},
{
"name": "shelf_static",
"version": "1.1.3",
"dependencies": [
"convert",
"http_parser",
"mime",
"path",
"shelf"
]
},
{
"name": "io",
"version": "1.0.5",
"dependencies": [
"meta",
"path",
"string_scanner"
]
},
{
"name": "http_multi_server",
"version": "3.2.2",
"dependencies": [
"async"
]
},
{
"name": "package_config",
"version": "3.0.0",
"dependencies": [
"meta"
]
},
{
"name": "matcher",
"version": "0.12.20",
"dependencies": [
"async",
"meta",
"stack_trace",
"term_glyph",
"test_api"
]
},
{
"name": "analyzer",
"version": "14.1.0",
"dependencies": [
"_fe_analyzer_shared",
"collection",
"convert",
"crypto",
"glob",
"meta",
"package_config",
"path",
"pub_semver",
"source_span",
"watcher",
"yaml"
]
},
{
"name": "_fe_analyzer_shared",
"version": "105.0.0",
"dependencies": [
"meta"
]
},
{
"name": "pub_semver",
"version": "2.2.0",
"dependencies": [
"collection"
]
},
{
"name": "args",
"version": "2.7.0",
"name": "mime",
"version": "2.0.0",
"dependencies": []
},
{
@@ -382,9 +413,12 @@
]
},
{
"name": "logging",
"version": "1.3.0",
"dependencies": []
"name": "cli_config",
"version": "0.2.0",
"dependencies": [
"args",
"yaml"
]
},
{
"name": "watcher",
@@ -395,60 +429,26 @@
]
},
{
"name": "shelf_web_socket",
"version": "3.0.0",
"name": "pub_semver",
"version": "2.2.0",
"dependencies": [
"shelf",
"stream_channel",
"web_socket_channel"
"collection"
]
},
{
"name": "mime",
"version": "2.0.0",
"dependencies": []
},
{
"name": "crypto",
"version": "3.0.7",
"name": "_fe_analyzer_shared",
"version": "105.0.0",
"dependencies": [
"typed_data"
"meta"
]
},
{
"name": "shelf",
"version": "1.4.2",
"name": "file",
"version": "7.0.1",
"dependencies": [
"async",
"collection",
"http_parser",
"path",
"stack_trace",
"stream_channel"
"meta",
"path"
]
},
{
"name": "web_socket_channel",
"version": "3.0.3",
"dependencies": [
"async",
"crypto",
"stream_channel",
"web",
"web_socket"
]
},
{
"name": "web_socket",
"version": "1.0.1",
"dependencies": [
"web"
]
},
{
"name": "vm_service",
"version": "15.2.0",
"dependencies": []
}
],
"configVersion": 1
@@ -0,0 +1,9 @@
library mam_core;
export 'src/command_runner.dart';
export 'src/models/drift_entry.dart';
export 'src/models/pane.dart';
export 'src/models/session_row.dart';
export 'src/models/sessions_snapshot.dart';
export 'src/services/session_service.dart';
export 'src/services/status_repository.dart';
@@ -0,0 +1,107 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
/// Result of one [runCommand] invocation.
///
/// When [timedOut] is true and the call was made with `killOnTimeout: false`,
/// [rc]/[stdout]/[stderr] are sentinel/empty — the real result only becomes
/// available via [backgroundFuture] once the still-running subprocess exits
/// on its own (D-Critical: a purge must never be reported as success/failure
/// before the actual process result is known).
class CommandResult {
final int rc;
final String stdout;
final String stderr;
final bool timedOut;
final Future<CommandResult>? backgroundFuture;
const CommandResult(
this.rc,
this.stdout,
this.stderr, {
this.timedOut = false,
this.backgroundFuture,
});
}
/// The only subprocess execution entry point in this codebase (D5). [argv]
/// is always an argv list handed straight to [Process.start] with shell
/// interpolation disabled, so there is no command-injection surface
/// regardless of what user input ends up inside [argv].
Future<CommandResult> runCommand(
List<String> argv, {
Duration? timeout,
Map<String, String>? environment,
String? workingDirectory,
bool killOnTimeout = true,
}) async {
if (argv.isEmpty) {
throw ArgumentError.value(argv, 'argv', 'must not be empty');
}
final process = await Process.start(
argv.first,
argv.sublist(1),
environment: environment,
workingDirectory: workingDirectory,
runInShell: false,
);
final stdoutBuffer = StringBuffer();
final stderrBuffer = StringBuffer();
final stdoutDone = process.stdout
.transform(utf8.decoder)
.listen(stdoutBuffer.write)
.asFuture<void>();
final stderrDone = process.stderr
.transform(utf8.decoder)
.listen(stderrBuffer.write)
.asFuture<void>();
Future<CommandResult> awaitExit() async {
final rc = await process.exitCode;
await stdoutDone;
await stderrDone;
return CommandResult(rc, stdoutBuffer.toString(), stderrBuffer.toString());
}
final exitFuture = awaitExit();
if (timeout == null) {
return exitFuture;
}
final timeoutMarker = Object();
final winner = await Future.any<Object>([
exitFuture,
Future<Object>.delayed(timeout, () => timeoutMarker),
]);
if (!identical(winner, timeoutMarker)) {
return winner as CommandResult;
}
// Timed out.
if (killOnTimeout) {
process.kill(ProcessSignal.sigterm);
final rc = await process.exitCode.timeout(
const Duration(seconds: 5),
onTimeout: () {
process.kill(ProcessSignal.sigkill);
return -9;
},
);
await stdoutDone;
await stderrDone;
return CommandResult(
rc,
stdoutBuffer.toString(),
stderrBuffer.toString(),
timedOut: true,
);
}
// Let the process keep running; hand back a future for its eventual result.
return CommandResult(-1, '', '',
timedOut: true, backgroundFuture: exitFuture);
}
@@ -0,0 +1,21 @@
import 'package:meta/meta.dart';
/// One entry from the top-level `drifts` list (reconcile.sh classes A/B/C/D),
/// not tied to a specific registered session row.
@immutable
class DriftEntry {
final String driftClass;
final String name;
final String msg;
const DriftEntry(
{required this.driftClass, required this.name, required this.msg});
factory DriftEntry.fromJson(Map<String, dynamic> json) {
return DriftEntry(
driftClass: json['class'] as String? ?? '?',
name: json['name'] as String? ?? '?',
msg: json['msg'] as String? ?? '',
);
}
}
@@ -0,0 +1,21 @@
import 'package:meta/meta.dart';
/// The tmux pane backing a session row, as reported by `sessions_detail`.
@immutable
class Pane {
final int? pid;
final String? cwd;
final String? cmd;
final String? cmdFull;
const Pane({this.pid, this.cwd, this.cmd, this.cmdFull});
factory Pane.fromSessionJson(Map<String, dynamic> json) {
return Pane(
pid: json['pane_pid'] as int?,
cwd: json['pane_cwd'] as String?,
cmd: json['cmd'] as String?,
cmdFull: json['cmd_full'] as String?,
);
}
}
@@ -0,0 +1,66 @@
import 'package:meta/meta.dart';
import 'pane.dart';
const _terminalStatuses = {'stopped', 'terminated', 'archived'};
/// One row of `sessions_detail`, matching the D8 contract in
/// `.mam/jobs/40bdce88/claude-reports/report-final.md` §3.1 field-for-field,
/// plus the additive pid/cmd_full/start_command/last_visible_status fields
/// the Detail Pane needs.
@immutable
class SessionRow {
final String name;
final String server;
final String status;
final bool tmuxAlive;
final Pane pane;
final String? role;
final String resumeState;
final String jobId;
final String jobStatus;
final String? attachCommand;
final String? startCommand;
final String? lastVisibleStatus;
final List<String> driftClasses;
const SessionRow({
required this.name,
required this.server,
required this.status,
required this.tmuxAlive,
required this.pane,
this.role,
required this.resumeState,
required this.jobId,
required this.jobStatus,
this.attachCommand,
this.startCommand,
this.lastVisibleStatus,
this.driftClasses = const [],
});
bool get hasDrift => driftClasses.isNotEmpty;
bool get isTerminal => _terminalStatuses.contains(status);
factory SessionRow.fromJson(Map<String, dynamic> json) {
return SessionRow(
name: json['name'] as String? ?? '?',
server: json['server'] as String? ?? 'default',
status: json['status'] as String? ?? '?',
tmuxAlive: json['tmux_alive'] as bool? ?? false,
pane: Pane.fromSessionJson(json),
role: json['role'] as String?,
resumeState: json['resume_state'] as String? ?? '?',
jobId: json['job_id'] as String? ?? '-',
jobStatus: json['job_status'] as String? ?? '-',
attachCommand: json['attach_command'] as String?,
startCommand: json['start_command'] as String?,
lastVisibleStatus: json['last_visible_status'] as String?,
driftClasses: (json['drift_classes'] as List<dynamic>? ?? const [])
.map((e) => e.toString())
.toList(growable: false),
);
}
}
@@ -0,0 +1,51 @@
import 'package:meta/meta.dart';
import 'drift_entry.dart';
import 'session_row.dart';
/// The full parsed result of one `status.sh --json` call: the untouched
/// pre-D8 keys (timestamp/yaml_path/tmux_sessions_alive/tmux_confirmed/
/// drifts/actions) plus the additive `sessions_detail` -> [SessionRow].
@immutable
class SessionsSnapshot {
final DateTime timestamp;
final String yamlPath;
final List<String> tmuxSessionsAlive;
final bool tmuxConfirmed;
final List<DriftEntry> drifts;
final List<String> actions;
final List<SessionRow> sessions;
const SessionsSnapshot({
required this.timestamp,
required this.yamlPath,
required this.tmuxSessionsAlive,
required this.tmuxConfirmed,
required this.drifts,
required this.actions,
required this.sessions,
});
factory SessionsSnapshot.fromJson(Map<String, dynamic> json) {
return SessionsSnapshot(
timestamp:
DateTime.tryParse(json['timestamp'] as String? ?? '')?.toUtc() ??
DateTime.now().toUtc(),
yamlPath: json['yaml_path'] as String? ?? '',
tmuxSessionsAlive:
(json['tmux_sessions_alive'] as List<dynamic>? ?? const [])
.map((e) => e.toString())
.toList(growable: false),
tmuxConfirmed: json['tmux_confirmed'] as bool? ?? false,
drifts: (json['drifts'] as List<dynamic>? ?? const [])
.map((e) => DriftEntry.fromJson(e as Map<String, dynamic>))
.toList(growable: false),
actions: (json['actions'] as List<dynamic>? ?? const [])
.map((e) => e.toString())
.toList(growable: false),
sessions: (json['sessions_detail'] as List<dynamic>? ?? const [])
.map((e) => SessionRow.fromJson(e as Map<String, dynamic>))
.toList(growable: false),
);
}
}
@@ -0,0 +1,58 @@
import 'dart:convert';
import '../command_runner.dart';
import '../models/sessions_snapshot.dart';
/// Thrown by [SessionService.fetchSnapshot] on any non-zero exit, timeout,
/// or malformed-JSON condition — callers (namely [StatusRepository]) use
/// this to distinguish "no fresh data this poll" from a genuine bug.
class StatusFetchException implements Exception {
final String message;
StatusFetchException(this.message);
@override
String toString() => 'StatusFetchException: $message';
}
/// Wraps `status.sh --json` as safely-parsed [SessionsSnapshot] models.
/// This is the single place that knows the on-disk path to `status.sh` and
/// the shape of its JSON output — nothing else in `mam_core` reads YAML,
/// SQLite, or jsonl directly (Rev.1 §1 principle).
class SessionService {
final String statusScriptPath;
final Duration timeout;
SessionService({
required this.statusScriptPath,
this.timeout = const Duration(seconds: 5),
});
Future<SessionsSnapshot> fetchSnapshot() async {
final result = await runCommand(
['bash', statusScriptPath, '--json'],
timeout: timeout,
);
if (result.timedOut) {
throw StatusFetchException('status.sh --json timed out after $timeout');
}
if (result.rc != 0) {
throw StatusFetchException(
'status.sh --json exited ${result.rc}: ${result.stderr.trim()}',
);
}
final Object? decoded;
try {
decoded = jsonDecode(result.stdout);
} on FormatException catch (e) {
throw StatusFetchException('malformed JSON from status.sh --json: $e');
}
if (decoded is! Map<String, dynamic>) {
throw StatusFetchException('status.sh --json returned non-object JSON');
}
return SessionsSnapshot.fromJson(decoded);
}
}
@@ -0,0 +1,68 @@
import 'package:meta/meta.dart';
import '../models/sessions_snapshot.dart';
import 'session_service.dart';
/// One tick of [StatusRepository.watch]: either a fresh [snapshot], or (on
/// poll failure) the last known-good snapshot marked [stale] (D6) — the UI
/// never sees a null/blank dashboard just because one poll failed.
@immutable
class SessionsPoll {
final SessionsSnapshot? snapshot;
final bool stale;
final DateTime? lastOkAt;
final String? error;
const SessionsPoll(
{this.snapshot, this.stale = false, this.lastOkAt, this.error});
}
/// Polls [SessionService] on an interval, applying the D6 contract: on
/// failure, keep serving the last good snapshot (marked stale) and back off
/// (3s -> 6s -> 15s) instead of hammering a broken `status.sh`. Framework
/// agnostic — `apps/mam_desktop` wires this into a Riverpod `StreamProvider`,
/// it does not reimplement the polling/backoff logic itself.
class StatusRepository {
final SessionService sessionService;
final Duration pollInterval;
final List<Duration> failureBackoff;
StatusRepository({
required this.sessionService,
this.pollInterval = const Duration(seconds: 4),
this.failureBackoff = const [
Duration(seconds: 3),
Duration(seconds: 6),
Duration(seconds: 15),
],
}) : assert(failureBackoff.isNotEmpty, 'failureBackoff must not be empty');
Stream<SessionsPoll> watch() async* {
SessionsSnapshot? lastGood;
DateTime? lastOkAt;
var failureStreak = 0;
while (true) {
try {
final snapshot = await sessionService.fetchSnapshot();
lastGood = snapshot;
lastOkAt = DateTime.now();
failureStreak = 0;
yield SessionsPoll(
snapshot: snapshot, stale: false, lastOkAt: lastOkAt);
await Future.delayed(pollInterval);
} catch (e) {
final idx = failureStreak.clamp(0, failureBackoff.length - 1);
final delay = failureBackoff[idx];
failureStreak++;
yield SessionsPoll(
snapshot: lastGood,
stale: lastGood != null,
lastOkAt: lastOkAt,
error: e.toString(),
);
await Future.delayed(delay);
}
}
}
}
@@ -0,0 +1,97 @@
import 'dart:convert';
import 'dart:io';
import 'package:mam_core/mam_core.dart';
import 'package:test/test.dart';
String _repoRoot() {
var dir = Directory.current;
while (!Directory('${dir.path}/.git').existsSync()) {
final parent = dir.parent;
if (parent.path == dir.path) {
throw StateError(
'could not locate repo root (.git) from ${Directory.current.path}');
}
dir = parent;
}
return dir.path;
}
void main() {
test('SessionsSnapshot.fromJson parses a well-formed sessions_detail payload',
() {
final json = jsonDecode('''
{
"timestamp": "2026-07-16T11:56:54Z",
"yaml_path": "/x/.mam/agent-sessions.yaml",
"tmux_sessions_alive": ["a|default"],
"tmux_confirmed": true,
"drifts": [{"class": "B", "name": "a", "msg": "registered: a"}],
"actions": ["registered: a"],
"sessions_detail": [
{
"name": "a", "server": "default", "status": "running",
"tmux_alive": true, "cmd": "claude", "role": "creator",
"resume_state": "yes", "job_id": "-", "job_status": "-",
"pane_cwd": "/x", "attach_command": "tmux attach -t a",
"drift_classes": ["B"], "pane_pid": 123, "cmd_full": "claude --foo",
"start_command": "tmux new-session ...", "last_visible_status": "running"
}
]
}
''') as Map<String, dynamic>;
final snapshot = SessionsSnapshot.fromJson(json);
expect(snapshot.tmuxConfirmed, isTrue);
expect(snapshot.drifts, hasLength(1));
expect(snapshot.drifts.single.driftClass, 'B');
expect(snapshot.sessions, hasLength(1));
final row = snapshot.sessions.single;
expect(row.name, 'a');
expect(row.role, 'creator');
expect(row.resumeState, 'yes');
expect(row.hasDrift, isTrue);
expect(row.isTerminal, isFalse);
expect(row.pane.pid, 123);
expect(row.pane.cmdFull, 'claude --foo');
expect(row.attachCommand, 'tmux attach -t a');
});
test('SessionsSnapshot.fromJson tolerates missing optional fields', () {
final json = jsonDecode('{"sessions_detail": [{"name": "bare"}]}')
as Map<String, dynamic>;
final snapshot = SessionsSnapshot.fromJson(json);
expect(snapshot.tmuxConfirmed, isFalse);
expect(snapshot.sessions.single.name, 'bare');
expect(snapshot.sessions.single.resumeState, '?');
expect(snapshot.sessions.single.pane.pid, isNull);
});
test(
'SessionService.fetchSnapshot parses the real status.sh --json output',
() async {
final scriptPath =
'${_repoRoot()}/.agents/skills/multi-agent-mux-status/scripts/status.sh';
if (!File(scriptPath).existsSync()) {
markTestSkipped(
'status.sh not found at $scriptPath in this environment');
return;
}
final service = SessionService(statusScriptPath: scriptPath);
final snapshot = await service.fetchSnapshot();
expect(snapshot.yamlPath, isNotEmpty);
// sessions_detail row count must match tmux_sessions_alive's distinct names.
final aliveNames = snapshot.tmuxSessionsAlive
.map((entry) => entry.split('|').first)
.toSet();
final detailNames = snapshot.sessions.map((s) => s.name).toSet();
expect(detailNames, containsAll(aliveNames.intersection(detailNames)));
},
timeout: const Timeout(Duration(seconds: 15)),
);
}