From 2eb85866b35ea185163b794377cb4291d5a7e461 Mon Sep 17 00:00:00 2001 From: Godopu Date: Thu, 16 Jul 2026 21:09:31 +0900 Subject: [PATCH] feat(ui): complete M1 Milestone - read-only Dashboard and Detail Pane with status.sh integration --- .../report-final.md | 362 +++++++++++ .../multi-agent-mux-status/scripts/status.sh | 108 +++- .../apps/mam_desktop/lib/main.dart | 216 ++++--- .../lib/src/providers/session_providers.dart | 26 + .../lib/src/status_script_locator.dart | 26 + .../mam_desktop/lib/src/theme/app_theme.dart | 50 ++ .../lib/src/widgets/detail_pane.dart | 263 ++++++++ .../lib/src/widgets/session_table.dart | 167 +++++ .../lib/src/widgets/stale_banner.dart | 47 ++ .../apps/mam_desktop/test/widget_test.dart | 82 ++- .../mam_core/.dart_tool/package_graph.json | 568 +++++++++--------- .../pub/bin/test/test.dart-3.12.1.snapshot | Bin 0 -> 26205920 bytes .../test/incremental_kernel.Ly9AZGFydD0zLjA= | Bin 0 -> 4027600 bytes .../packages/mam_core/lib/mam_core.dart | 9 + .../mam_core/lib/src/command_runner.dart | 107 ++++ .../mam_core/lib/src/models/drift_entry.dart | 21 + .../mam_core/lib/src/models/pane.dart | 21 + .../mam_core/lib/src/models/session_row.dart | 66 ++ .../lib/src/models/sessions_snapshot.dart | 51 ++ .../lib/src/services/session_service.dart | 58 ++ .../lib/src/services/status_repository.dart | 68 +++ .../mam_core/test/session_service_test.dart | 97 +++ 22 files changed, 2014 insertions(+), 399 deletions(-) create mode 100644 .agents/reports/canary-projects-multi-agent-mux-planner-reviewer-claude/report-final.md create mode 100644 .agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/providers/session_providers.dart create mode 100644 .agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/status_script_locator.dart create mode 100644 .agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/theme/app_theme.dart create mode 100644 .agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/widgets/detail_pane.dart create mode 100644 .agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/widgets/session_table.dart create mode 100644 .agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/widgets/stale_banner.dart create mode 100644 .agents/skills/multi-agent-mux-ui/packages/mam_core/.dart_tool/pub/bin/test/test.dart-3.12.1.snapshot create mode 100644 .agents/skills/multi-agent-mux-ui/packages/mam_core/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjA= create mode 100644 .agents/skills/multi-agent-mux-ui/packages/mam_core/lib/mam_core.dart create mode 100644 .agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/command_runner.dart create mode 100644 .agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/drift_entry.dart create mode 100644 .agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/pane.dart create mode 100644 .agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/session_row.dart create mode 100644 .agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/sessions_snapshot.dart create mode 100644 .agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/services/session_service.dart create mode 100644 .agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/services/status_repository.dart create mode 100644 .agents/skills/multi-agent-mux-ui/packages/mam_core/test/session_service_test.dart diff --git a/.agents/reports/canary-projects-multi-agent-mux-planner-reviewer-claude/report-final.md b/.agents/reports/canary-projects-multi-agent-mux-planner-reviewer-claude/report-final.md new file mode 100644 index 0000000..0ec79a2 --- /dev/null +++ b/.agents/reports/canary-projects-multi-agent-mux-planner-reviewer-claude/report-final.md @@ -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 [--purge-conversation] [--yes] [--reason ]` | ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ ํ†ต๊ณผ ํ›„ ๊ทธ๋Œ€๋กœ ํ˜ธ์ถœ | +| ์„ธ์…˜ ์ƒ์„ฑ | `.agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace --agent --role [--session ] [--onboard] [--submit-job ]` | New ๋งˆ๋ฒ•์‚ฌ ํผ โ†’ argv ๋งคํ•‘ | +| ์„ธ์…˜ ์žฌ๊ฐœ | `.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh --workspace --agent [--session ]` + `update_yaml_resumed.sh --session --uuid [--agent ]` | 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/.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 attach -t + 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? backgroundFuture; // timedOut=true & killOnTimeout=false ์ผ ๋•Œ, ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์™„์ฃผ ํ›„ ์ตœ์ข… ๊ฒฐ๊ณผ๋ฅผ ๋ฐ›์„ ํ•ธ๋“ค (D-Critical ๋ณด์™„) + CommandResult(this.rc, this.stdout, this.stderr, this.timedOut, {this.backgroundFuture}); +} + +Future runCommand( + List argv, { + Duration? timeout, + Map? environment, + bool killOnTimeout = true, // D-Critical ๋Œ€์‘: purge ํ˜ธ์ถœ๋ถ€๋Š” false๋กœ ์ „๋‹ฌ (ยง6.3) +}) async { + // 1) argv๋Š” ํ•ญ์ƒ List โ€” 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>`(์„ธ์…˜๋ช… โ†’ ์ง„ํ–‰์ค‘ ์—ฌ๋ถ€)์„ ๋‘๊ณ , 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` 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 attach -t `๋ฅผ 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.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] diff --git a/.agents/skills/multi-agent-mux-status/scripts/status.sh b/.agents/skills/multi-agent-mux-status/scripts/status.sh index 9ffeae6..f8005c9 100755 --- a/.agents/skills/multi-agent-mux-status/scripts/status.sh +++ b/.agents/skills/multi-agent-mux-status/scripts/status.sh @@ -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 diff --git a/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/main.dart b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/main.dart index 244a702..3538563 100644 --- a/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/main.dart +++ b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/main.dart @@ -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 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 { - 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 []; + 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, + ), + ), + ), + ], ), ); } diff --git a/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/providers/session_providers.dart b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/providers/session_providers.dart new file mode 100644 index 0000000..b328d5c --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/providers/session_providers.dart @@ -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((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((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((ref) { + final repo = ref.watch(statusRepositoryProvider); + return repo.watch(); +}); + +/// Currently selected row name for the Master-Detail layout. +final selectedSessionNameProvider = StateProvider((ref) => null); diff --git a/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/status_script_locator.dart b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/status_script_locator.dart new file mode 100644 index 0000000..08c61c6 --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/status_script_locator.dart @@ -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; +} diff --git a/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/theme/app_theme.dart b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/theme/app_theme.dart new file mode 100644 index 0000000..a1620f8 --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/theme/app_theme.dart @@ -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), + ), + ), + ); +} diff --git a/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/widgets/detail_pane.dart b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/widgets/detail_pane.dart new file mode 100644 index 0000000..c1bd648 --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/widgets/detail_pane.dart @@ -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)), + ), + ), + ], + ), + ); + } +} diff --git a/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/widgets/session_table.dart b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/widgets/session_table.dart new file mode 100644 index 0000000..a298592 --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/widgets/session_table.dart @@ -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 sessions; + final String? selectedName; + final ValueChanged 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, + ), + ); + } +} diff --git a/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/widgets/stale_banner.dart b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/widgets/stale_banner.dart new file mode 100644 index 0000000..9102f0f --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/lib/src/widgets/stale_banner.dart @@ -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, + ), + ), + ], + ), + ); + } +} diff --git a/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/test/widget_test.dart b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/test/widget_test.dart index df71b06..6956231 100644 --- a/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/test/widget_test.dart +++ b/.agents/skills/multi-agent-mux-ui/apps/mam_desktop/test/widget_test.dart @@ -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); }); } diff --git a/.agents/skills/multi-agent-mux-ui/packages/mam_core/.dart_tool/package_graph.json b/.agents/skills/multi-agent-mux-ui/packages/mam_core/.dart_tool/package_graph.json index bec5cd2..5d24591 100644 --- a/.agents/skills/multi-agent-mux-ui/packages/mam_core/.dart_tool/package_graph.json +++ b/.agents/skills/multi-agent-mux-ui/packages/mam_core/.dart_tool/package_graph.json @@ -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 diff --git a/.agents/skills/multi-agent-mux-ui/packages/mam_core/.dart_tool/pub/bin/test/test.dart-3.12.1.snapshot b/.agents/skills/multi-agent-mux-ui/packages/mam_core/.dart_tool/pub/bin/test/test.dart-3.12.1.snapshot new file mode 100644 index 0000000000000000000000000000000000000000..0cc5ce9f1c2f26020daf3d30d9915d0b3611c07c GIT binary patch literal 26205920 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