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 0000000..0cc5ce9 Binary files /dev/null and b/.agents/skills/multi-agent-mux-ui/packages/mam_core/.dart_tool/pub/bin/test/test.dart-3.12.1.snapshot differ diff --git a/.agents/skills/multi-agent-mux-ui/packages/mam_core/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjA= b/.agents/skills/multi-agent-mux-ui/packages/mam_core/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjA= new file mode 100644 index 0000000..46da498 Binary files /dev/null and b/.agents/skills/multi-agent-mux-ui/packages/mam_core/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjA= differ diff --git a/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/mam_core.dart b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/mam_core.dart new file mode 100644 index 0000000..71c8805 --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/mam_core.dart @@ -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'; diff --git a/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/command_runner.dart b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/command_runner.dart new file mode 100644 index 0000000..ab5143f --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/command_runner.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? 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 runCommand( + List argv, { + Duration? timeout, + Map? 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(); + final stderrDone = process.stderr + .transform(utf8.decoder) + .listen(stderrBuffer.write) + .asFuture(); + + Future 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([ + exitFuture, + Future.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); +} diff --git a/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/drift_entry.dart b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/drift_entry.dart new file mode 100644 index 0000000..209a59c --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/drift_entry.dart @@ -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 json) { + return DriftEntry( + driftClass: json['class'] as String? ?? '?', + name: json['name'] as String? ?? '?', + msg: json['msg'] as String? ?? '', + ); + } +} diff --git a/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/pane.dart b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/pane.dart new file mode 100644 index 0000000..75fa56f --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/pane.dart @@ -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 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?, + ); + } +} diff --git a/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/session_row.dart b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/session_row.dart new file mode 100644 index 0000000..d7352a8 --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/session_row.dart @@ -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 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 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? ?? const []) + .map((e) => e.toString()) + .toList(growable: false), + ); + } +} diff --git a/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/sessions_snapshot.dart b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/sessions_snapshot.dart new file mode 100644 index 0000000..f905991 --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/models/sessions_snapshot.dart @@ -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 tmuxSessionsAlive; + final bool tmuxConfirmed; + final List drifts; + final List actions; + final List 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 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? ?? const []) + .map((e) => e.toString()) + .toList(growable: false), + tmuxConfirmed: json['tmux_confirmed'] as bool? ?? false, + drifts: (json['drifts'] as List? ?? const []) + .map((e) => DriftEntry.fromJson(e as Map)) + .toList(growable: false), + actions: (json['actions'] as List? ?? const []) + .map((e) => e.toString()) + .toList(growable: false), + sessions: (json['sessions_detail'] as List? ?? const []) + .map((e) => SessionRow.fromJson(e as Map)) + .toList(growable: false), + ); + } +} diff --git a/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/services/session_service.dart b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/services/session_service.dart new file mode 100644 index 0000000..6ffd014 --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/services/session_service.dart @@ -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 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) { + throw StatusFetchException('status.sh --json returned non-object JSON'); + } + + return SessionsSnapshot.fromJson(decoded); + } +} diff --git a/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/services/status_repository.dart b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/services/status_repository.dart new file mode 100644 index 0000000..b704bd4 --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/packages/mam_core/lib/src/services/status_repository.dart @@ -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 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 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); + } + } + } +} diff --git a/.agents/skills/multi-agent-mux-ui/packages/mam_core/test/session_service_test.dart b/.agents/skills/multi-agent-mux-ui/packages/mam_core/test/session_service_test.dart new file mode 100644 index 0000000..1529240 --- /dev/null +++ b/.agents/skills/multi-agent-mux-ui/packages/mam_core/test/session_service_test.dart @@ -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; + + 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; + 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)), + ); +}