coxpit 4.8.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "4.8.0",
3
+ "version": "5.0.0",
4
4
  "description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/board.ts CHANGED
@@ -23,8 +23,12 @@ export const BOARD_HTML = /* html */ `<!doctype html>
23
23
  *{box-sizing:border-box}
24
24
  [hidden]{display:none !important}
25
25
  html,body{height:100%}
26
+ /* mobile stability — kill the iOS left-right rubber-band + horizontal pan.
27
+ overscroll-behavior on the root stops the rubber-band; overflow-x:hidden is on BODY only
28
+ (NOT html — overflow on html breaks iOS position:fixed/sticky, which the header + drawer use). */
29
+ html{overscroll-behavior:none}
26
30
  body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
27
- -webkit-font-smoothing:antialiased}
31
+ -webkit-font-smoothing:antialiased;overflow-x:hidden;overscroll-behavior-x:none}
28
32
  ::selection{background:var(--brand-dim)}
29
33
  ::-webkit-scrollbar{width:9px;height:9px}
30
34
  ::-webkit-scrollbar-thumb{background:#252c3a;border-radius:5px;border:2px solid var(--bg)}
@@ -82,6 +86,94 @@ export const BOARD_HTML = /* html */ `<!doctype html>
82
86
  color:var(--faint);margin:2px 0 -2px}
83
87
  .btn[disabled]{opacity:.4;cursor:not-allowed;filter:none}
84
88
 
89
+ /* ── navigator rail (v5.0) — machine · repos · view nav · New ── */
90
+ aside.rail{padding:12px 10px;gap:4px;overflow-y:auto}
91
+ .rail .machine{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11.5px;
92
+ color:var(--ink);border:1px solid var(--line-hi);border-radius:8px;padding:7px 10px;margin-bottom:6px;
93
+ cursor:pointer;background:transparent;text-align:left;width:100%}
94
+ .rail .machine:hover{border-color:rgba(78,201,176,.4)}
95
+ .rail .machine .ic{width:14px;height:14px;color:var(--muted)}
96
+ .rail .machine .msp{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
97
+ .rail .machine .mdot2{width:6px;height:6px;border-radius:50%;background:var(--faint);flex:none}
98
+ .rail .machine .mdot2.on{background:var(--s-done)}
99
+ .rlabel{font-family:var(--mono);font-size:9.5px;text-transform:uppercase;letter-spacing:.13em;
100
+ color:var(--faint);margin:8px 6px 4px}
101
+ /* rail repo rows — #repoList + the add row(#repoAdd) 만 (라이브러리 캡처 .repo 는 건드리지 않음) */
102
+ #repoList .repo,.repo.add{display:flex;align-items:center;gap:9px;padding:7px 10px;border-radius:8px;cursor:pointer;
103
+ color:var(--muted);font-family:var(--sans);font-size:13px;border:none;background:transparent;
104
+ position:relative;width:100%;text-align:left}
105
+ #repoList .repo:hover,.repo.add:hover{background:var(--surface2);color:var(--ink)}
106
+ #repoList .repo.on{background:var(--brand-dim);color:var(--ink)}
107
+ #repoList .repo .ic,.repo.add .ic{width:15px;height:15px;color:var(--faint)}
108
+ #repoList .repo.on .ic{color:var(--brand)}
109
+ #repoList .repo .nm,.repo.add .nm{flex:1;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:inherit}
110
+ #repoList .repo .cnt{font-family:var(--mono);font-size:10px;color:var(--brand);background:var(--brand-dim);
111
+ border-radius:99px;padding:1px 7px;flex:none}
112
+ #repoList .repo .attn{width:7px;height:7px;border-radius:50%;background:var(--s-failed);flex:none;
113
+ box-shadow:0 0 0 2px var(--surface)}
114
+ .repo.add{color:var(--faint);font-size:12px}
115
+ #repoList .repo .rowmenu{display:none;align-items:center;gap:2px;flex:none}
116
+ #repoList .repo:hover .rowmenu{display:flex}
117
+ #repoList .repo:hover .cnt{display:none}
118
+ #repoList .repo .rmbtn{background:none;border:none;color:var(--faint);cursor:pointer;padding:2px;
119
+ display:inline-flex;border-radius:5px}
120
+ #repoList .repo .rmbtn:hover{color:var(--ink);background:var(--line)}
121
+ #repoList .repo .rmbtn .ic{width:13px;height:13px;color:inherit}
122
+ .navi{display:flex;align-items:center;gap:9px;padding:7px 10px;border-radius:8px;color:var(--muted);
123
+ cursor:pointer;font-size:13px;border:none;background:transparent;width:100%;text-align:left;font-family:var(--sans)}
124
+ .navi:hover{background:var(--surface2)}
125
+ .navi.on{background:var(--surface2);color:var(--ink)}
126
+ .navi .ic{width:15px;height:15px}
127
+ .navi.on .ic{color:var(--brand)}
128
+ .navi .nsp{flex:1}
129
+ .navi .n{font-family:var(--mono);font-size:10.5px;color:var(--faint)}
130
+ .railsp{flex:1;min-height:8px}
131
+ .newbtn{display:flex;align-items:center;justify-content:center;gap:7px;background:var(--brand);
132
+ color:var(--brand-ink);border:none;border-radius:var(--r-ctl);font-family:var(--sans);font-weight:700;
133
+ font-size:13px;padding:10px;cursor:pointer;margin-top:8px}
134
+ .newbtn:hover{filter:brightness(1.08)}
135
+ .newbtn[disabled]{opacity:.4;cursor:not-allowed;filter:none}
136
+ .newbtn .ic{width:16px;height:16px}
137
+ .newnote{font-family:var(--mono);font-size:9.5px;color:var(--faint);text-align:center;margin-top:5px}
138
+ /* mobile FAB (v5.0 pocket board) — floating + opens the compose sheet; hidden on desktop */
139
+ .fab{display:none;position:fixed;z-index:28;right:18px;
140
+ right:calc(18px + env(safe-area-inset-right));bottom:20px;bottom:calc(20px + env(safe-area-inset-bottom));
141
+ width:52px;height:52px;border-radius:50%;border:none;background:var(--brand);color:var(--brand-ink);
142
+ box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;align-items:center;justify-content:center}
143
+ .fab:active{filter:brightness(.94)}
144
+ .fab .ic{width:24px;height:24px}
145
+ /* machine menu (native select 대체) — 레일 컨텍스트 앵커 */
146
+ .mmenu{position:absolute;z-index:45;min-width:170px;background:var(--surface2);border:1px solid var(--line-hi);
147
+ border-radius:9px;box-shadow:var(--shadow);padding:4px;display:none}
148
+ .mmenu.open{display:block}
149
+ .mmenu .mopt{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:6px;
150
+ font-family:var(--mono);font-size:12px;color:var(--muted);cursor:pointer;white-space:nowrap}
151
+ .mmenu .mopt:hover{background:var(--surface);color:var(--ink)}
152
+ .mmenu .mopt.on{color:var(--brand)}
153
+ .mmenu .mopt .mdot2{width:6px;height:6px;border-radius:50%;background:var(--faint);flex:none}
154
+ .mmenu .mopt .mdot2.on{background:var(--s-done)}
155
+ /* ── New sheet (v5.0 Part B — focused compose Task|Goal|Workbench) ── */
156
+ .sheet{width:min(400px,94vw);max-height:88vh;background:var(--surface);
157
+ border:1px solid var(--line-hi);border-radius:14px;box-shadow:var(--shadow);
158
+ display:flex;flex-direction:column;gap:13px;padding:16px 18px 16px}
159
+ .sheet-h{display:flex;align-items:center;gap:9px;font-family:var(--mono);font-size:12px}
160
+ .sheet-h .t{color:var(--ink);font-weight:700}.sheet-h .r{color:var(--faint);overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
161
+ .sheet-h .sp{flex:1}.sheet-h .x{color:var(--muted);cursor:pointer;background:none;border:none;padding:2px}
162
+ .sheet #taskForm{display:flex;flex-direction:column;gap:13px;min-height:0}
163
+ .sheet-body{display:flex;flex-direction:column;gap:8px;overflow-y:auto;min-height:0;max-height:60vh;padding:1px}
164
+ .sheet-f{display:flex;align-items:center;gap:8px;padding-top:12px;border-top:1px solid var(--line)}
165
+ .sheet-f #modeSeg{flex:1}
166
+ .sheet-f #runFleetBtn{flex:0 0 auto}
167
+ /* progressive Options reveal (rarely-used Task fields) */
168
+ .opt{border:1px solid var(--line);border-radius:var(--r-ctl);background:#0e1118;overflow:hidden}
169
+ .opt-h{width:100%;display:flex;align-items:center;gap:8px;background:transparent;border:none;cursor:pointer;
170
+ padding:8px 10px;font-size:12px;font-weight:600;color:var(--muted);text-align:left}
171
+ .opt-h:hover{color:var(--ink)}
172
+ .opt-h .opt-car{width:14px;height:14px;color:var(--faint);transform:rotate(0deg);transition:transform .16s}
173
+ .opt-h[aria-expanded="true"] .opt-car{transform:rotate(90deg)}
174
+ .opt-h .opt-sub{margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--faint);font-weight:500}
175
+ .opt-b{display:flex;flex-direction:column;gap:8px;padding:2px 10px 11px}
176
+
85
177
  /* ── repo browser ── */
86
178
  .brw{width:min(560px,94vw);max-height:78vh;background:var(--surface);border:1px solid var(--line);
87
179
  border-radius:14px;box-shadow:var(--shadow);display:flex;flex-direction:column;overflow:hidden}
@@ -478,8 +570,8 @@ export const BOARD_HTML = /* html */ `<!doctype html>
478
570
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
479
571
 
480
572
  /* ── AI review panel (compare) ── */
481
- .cmp-review{border-bottom:1px solid var(--line);background:var(--surface2);padding:14px 20px;
482
- max-height:42vh;overflow:auto;font-size:13px;line-height:1.65;color:var(--muted)}
573
+ .cmp-review{flex:1;min-height:0;overflow:auto;background:var(--surface2);
574
+ padding:20px max(20px,calc((100% - 880px)/2));font-size:13.5px;line-height:1.7;color:var(--muted)}
483
575
  .cmp-review[hidden]{display:none}
484
576
  .cmp-review h2{font-size:13px;color:var(--brand);margin:14px 0 6px;letter-spacing:.02em}
485
577
  .cmp-review h3{font-size:12.5px;color:var(--ink);margin:12px 0 4px}
@@ -507,7 +599,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
507
599
  #outDetail .dl-line{cursor:pointer;border-radius:3px}
508
600
  #outDetail .dl-line:hover{background:rgba(78,201,176,.09)}
509
601
 
510
- /* ── v4.7 산출물 계약(contract strip) — 모달 헤더 아래 요청 산출물 요약 ── */
602
+ /* ── v4.7 outputs contract (contract strip) — required-outputs summary under the modal header ── */
511
603
  .contract{display:flex;flex-wrap:wrap;align-items:center;gap:7px;padding:9px 18px;
512
604
  border-bottom:1px solid var(--line);background:var(--surface2);
513
605
  font-family:var(--mono);font-size:10.5px;color:var(--faint);line-height:1.5}
@@ -580,11 +672,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
580
672
  .layout{grid-template-columns:1fr;min-height:calc(100dvh - 55px)}
581
673
  /* 사이드바 = 오프캔버스 드로어 — 플릿이 첫 화면, 런처는 ☰ 뒤에 */
582
674
  .menu-btn{display:inline-flex}
583
- aside{position:fixed;top:54px;bottom:0;left:0;width:min(86vw,340px);z-index:30;
584
- transform:translateX(-103%);transition:transform .22s ease;overflow-y:auto;
675
+ aside{position:fixed;top:54px;bottom:0;left:0;width:min(86vw,340px);max-width:100vw;z-index:30;
676
+ transform:translateX(-103%);transition:transform .22s ease;overflow-y:auto;overflow-x:hidden;
585
677
  -webkit-overflow-scrolling:touch;box-shadow:var(--shadow)}
586
678
  aside.open{transform:translateX(0)}
587
679
  .scrim.on{display:block}
680
+ /* pocket board — drawer is JUST the navigator; the + FAB opens the compose sheet */
681
+ .fab{display:inline-flex}
682
+ #newBtn,.newnote{display:none}
588
683
  header{padding:0 12px;gap:9px}
589
684
  .brand .sub,.daemon-badge,.machines{display:none}
590
685
  main{padding:14px}
@@ -615,25 +710,74 @@ ${ICON_SPRITE}
615
710
  <button class="btn-ghost sm menu-btn" id="menuBtn" aria-label="open launcher">☰</button>
616
711
  <div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
617
712
  <span class="daemon-badge" id="daemonBadge" style="display:none"></span>
618
- <div class="seg view-seg" id="viewSeg">
619
- <button type="button" class="seg-opt on" data-view="active">Active</button>
620
- <button type="button" class="seg-opt" data-view="archive">Archive <span id="archN" class="seg-hint"></span></button>
621
- </div>
622
713
  <div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
623
714
  <button class="btn-ghost sm" id="bell" title="notify when a run settles"><svg class="ic"><use href="#i-bell-off"/></svg></button>
624
715
  <button class="btn-ghost sm" id="remoteBtn" title="reach this daemon from elsewhere (Tailscale · recipes)"><svg class="ic"><use href="#i-external-link"/></svg></button>
625
716
  <div class="machines" id="machines"></div>
626
717
  </header>
627
718
  <div class="scrim" id="scrim"></div>
719
+ <!-- pocket board FAB (v5.0 Part C) — mobile-only; opens the compose sheet -->
720
+ <button type="button" class="fab" id="fab" aria-label="new task"><svg class="ic"><use href="#i-plus"/></svg></button>
628
721
  <div class="layout">
629
- <aside>
630
- <div class="sect">
631
- <p class="sect-label">Context</p>
632
- <p class="flabel">machine</p>
633
- <select id="repoMachine"></select>
634
- <p class="flabel">repository</p>
635
- <select id="taskRepo"></select>
636
- <div class="row">
722
+ <aside class="rail">
723
+ <button type="button" class="machine" id="machineSwitch">
724
+ <svg class="ic"><use href="#i-server"/></svg>
725
+ <span class="msp" id="machineLbl">local</span>
726
+ <span class="mdot2" id="machineDot"></span>
727
+ </button>
728
+ <div class="mmenu" id="machineMenu"></div>
729
+
730
+ <p class="rlabel">Repositories</p>
731
+ <div id="repoList"></div>
732
+ <button type="button" class="repo add" id="repoAdd">
733
+ <svg class="ic"><use href="#i-plus"/></svg><span class="nm">Add repository…</span>
734
+ </button>
735
+
736
+ <p class="rlabel">View</p>
737
+ <nav id="viewNav">
738
+ <button type="button" class="navi on" data-view="active">
739
+ <svg class="ic"><use href="#i-layers"/></svg><span>Active</span><span class="nsp"></span><span class="n" id="navActiveN"></span>
740
+ </button>
741
+ <button type="button" class="navi" data-view="goals">
742
+ <svg class="ic"><use href="#i-target"/></svg><span>Goals</span><span class="nsp"></span><span class="n" id="navGoalsN"></span>
743
+ </button>
744
+ <button type="button" class="navi" data-view="archive">
745
+ <svg class="ic"><use href="#i-archive"/></svg><span>Archive</span><span class="nsp"></span><span class="n" id="navArchiveN"></span>
746
+ </button>
747
+ </nav>
748
+
749
+ <div class="railsp"></div>
750
+ <button type="button" class="newbtn" id="newBtn"><svg class="ic"><use href="#i-plus"/></svg> New</button>
751
+ <p class="newnote">Task · Goal · Workbench</p>
752
+
753
+ <details class="sect" id="libraryBox" style="margin-top:12px">
754
+ <summary class="sect-label" style="cursor:pointer;list-style:none">Library · design captures ▾</summary>
755
+ <div id="captures" style="display:flex;flex-direction:column;gap:6px;margin-top:10px"></div>
756
+ <a id="bmk" class="btn-ghost sm" style="text-decoration:none;text-align:center;display:block;padding:6px;margin-top:6px"
757
+ title="drag me to your bookmarks bar, then click it on your running app">⌖ coxpit inspect</a>
758
+ <span style="font-size:11px;color:var(--faint)">Drag to bookmarks. Click it on your app, then click an element.
759
+ With auth on, append ?k=&lt;pass&gt; to the script URL.</span>
760
+ </details>
761
+ </aside>
762
+
763
+ <!-- New sheet (v5.0 Part B — focused compose: Task | Goal | Workbench) -->
764
+ <div class="overlay" id="newSheet">
765
+ <div class="sheet" id="sheetCard">
766
+ <div class="sheet-h"><svg class="ic" style="color:var(--brand)"><use href="#i-plus"/></svg>
767
+ <span class="t">New</span><span class="r" id="sheetRepoLbl"></span><span class="sp"></span>
768
+ <button type="button" class="x" id="sheetClose" aria-label="close"><svg class="ic"><use href="#i-x"/></svg></button></div>
769
+
770
+ <div class="seg" id="launchTabs" role="group" aria-label="launch type">
771
+ <button type="button" class="seg-opt on" data-tab="task">Task</button>
772
+ <button type="button" class="seg-opt" data-tab="goal">Goal</button>
773
+ <button type="button" class="seg-opt" data-tab="bench">Workbench</button>
774
+ </div>
775
+
776
+ <!-- hidden state selects (target machine/repo — driven by the rail + repo picker) -->
777
+ <select id="repoMachine" hidden></select>
778
+ <select id="taskRepo" hidden></select>
779
+ <!-- repo picker affordances (opened from the Add-repository rail button / handlers) -->
780
+ <div class="row" id="repoActions" hidden>
637
781
  <button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
638
782
  <button type="button" class="btn-ghost sm" id="repoNew" style="flex:0 0 auto" title="start a new project — empty folder in, scaffolded repo out">New</button>
639
783
  <button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
@@ -646,68 +790,68 @@ ${ICON_SPRITE}
646
790
  <button class="btn-ghost sm" type="submit" style="flex:0 0 auto">Register</button>
647
791
  </div>
648
792
  </form>
649
- </div>
650
793
 
651
- <div class="sect">
652
- <p class="sect-label">Start</p>
653
- <div class="seg" id="launchTabs">
654
- <button type="button" class="seg-opt on" data-tab="task">Task</button>
655
- <button type="button" class="seg-opt" data-tab="goal">Goal</button>
656
- <button type="button" class="seg-opt" data-tab="bench">Workbench</button>
657
- </div>
658
794
  <form id="taskForm">
659
- <div id="panelTask" style="display:flex;flex-direction:column;gap:8px">
660
- <input id="taskTitle" placeholder="Task title" />
661
- <textarea id="taskPrompt" placeholder="Prompt target files, constraints, how to verify"></textarea>
662
- <button type="button" class="btn-ghost sm" id="ghImport">From GitHub issue / PR…</button>
663
- <div class="seg" id="provSeg" role="group" aria-label="agent provider">
664
- <button type="button" class="seg-opt on" data-agent="claude-code">Claude</button>
665
- <button type="button" class="seg-opt" data-agent="codex">Codex</button>
795
+ <div class="sheet-body">
796
+ <div id="panelTask" style="display:flex;flex-direction:column;gap:8px">
797
+ <input id="taskTitle" placeholder="Task title" />
798
+ <textarea id="taskPrompt" placeholder="Prompt target files, constraints, how to verify"></textarea>
799
+ <button type="button" class="btn-ghost sm" id="ghImport">From GitHub issue / PR…</button>
800
+ <div class="seg" id="provSeg" role="group" aria-label="agent provider">
801
+ <button type="button" class="seg-opt on" data-agent="claude-code">Claude</button>
802
+ <button type="button" class="seg-opt" data-agent="codex">Codex</button>
803
+ </div>
804
+ <div class="opt" id="taskOpt">
805
+ <button type="button" class="opt-h" id="taskOptToggle" aria-expanded="false">
806
+ <svg class="ic opt-car"><use href="#i-chevron"/></svg>
807
+ <span>Options</span><span class="opt-sub">model · design · deliverables</span>
808
+ </button>
809
+ <div class="opt-b" id="taskOptBody" hidden>
810
+ <p class="flabel">model · optional</p>
811
+ <input id="taskModel" placeholder="CLI default" list="modelHist" autocomplete="off" />
812
+ <datalist id="modelHist"></datalist>
813
+ <p class="flabel">design capture · optional</p>
814
+ <select id="taskCapture"><option value="">no design capture</option></select>
815
+ <p class="flabel">deliverables · optional (contract)</p>
816
+ <div class="ochips" id="taskOutputs" role="group" aria-label="declared deliverables">
817
+ <button type="button" class="ochip" data-out="answer">Answer</button>
818
+ <button type="button" class="ochip" data-out="code">Code</button>
819
+ <button type="button" class="ochip" data-out="doc">Doc</button>
820
+ <button type="button" class="ochip" data-out="page">Page</button>
821
+ <button type="button" class="ochip" data-out="file">File</button>
822
+ </div>
823
+ </div>
824
+ </div>
666
825
  </div>
667
- <p class="flabel">model · optional</p>
668
- <input id="taskModel" placeholder="CLI default" list="modelHist" autocomplete="off" />
669
- <datalist id="modelHist"></datalist>
670
- <p class="flabel">design capture · optional</p>
671
- <select id="taskCapture"><option value="">no design capture</option></select>
672
- <p class="flabel">deliverables · optional (계약)</p>
673
- <div class="ochips" id="taskOutputs" role="group" aria-label="declared deliverables">
674
- <button type="button" class="ochip" data-out="answer">답변</button>
675
- <button type="button" class="ochip" data-out="code">코드</button>
676
- <button type="button" class="ochip" data-out="doc">문서</button>
677
- <button type="button" class="ochip" data-out="page">페이지</button>
678
- <button type="button" class="ochip" data-out="file">파일</button>
826
+ <div id="panelGoal" hidden style="flex-direction:column;gap:8px">
827
+ <textarea id="planGoal" placeholder="One goal a planner agent reads the repo, splits it into independent tasks, and launches them all. Converge with Select runs → Integrate."></textarea>
828
+ <div class="seg" id="provSegGoal" role="group" aria-label="agent provider">
829
+ <button type="button" class="seg-opt on" data-agent="claude-code">Claude</button>
830
+ <button type="button" class="seg-opt" data-agent="codex">Codex</button>
831
+ </div>
832
+ </div>
833
+ <div id="panelBench" hidden style="flex-direction:column;gap:8px">
834
+ <input id="benchTitle" placeholder="Workbench name · optional" />
835
+ <p style="font-size:11.5px;color:var(--faint);margin:0;line-height:1.55">Isolated worktree + terminal. Work interactively — run <span style="color:var(--brand);font-family:var(--mono)">claude</span> inside, take hours — then decide the merge from the card.</p>
679
836
  </div>
680
837
  </div>
681
- <div id="panelGoal" hidden style="flex-direction:column;gap:8px">
682
- <textarea id="planGoal" placeholder="One goal — a planner agent reads the repo, splits it into independent tasks, and launches them all. Converge with Select runs → Integrate."></textarea>
683
- </div>
684
- <div id="panelBench" hidden style="flex-direction:column;gap:8px">
685
- <input id="benchTitle" placeholder="Workbench name · optional" />
686
- <p style="font-size:11.5px;color:var(--faint);margin:0;line-height:1.55">Isolated worktree + terminal. Work interactively — run <span style="color:var(--brand);font-family:var(--mono)">claude</span> inside, take hours — then decide the merge from the card.</p>
687
- </div>
688
- <div class="seg" id="modeSeg" role="group" aria-label="agent mode">
689
- <button type="button" class="seg-opt" data-real="0">Dry run</button>
690
- <button type="button" class="seg-opt" data-real="1">Real agent<span class="seg-hint">spends credits</span></button>
691
- </div>
692
- <input type="checkbox" id="taskReal" hidden />
693
- <div class="row">
838
+ <div class="sheet-f" id="sheetFooter">
839
+ <div class="seg" id="modeSeg" role="group" aria-label="agent mode">
840
+ <button type="button" class="seg-opt" data-real="0">Dry run</button>
841
+ <button type="button" class="seg-opt" data-real="1">Real agent<span class="seg-hint">spends credits</span></button>
842
+ </div>
843
+ <input type="checkbox" id="taskReal" hidden />
694
844
  <input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="agents — 1 for a job, N to explore variants" />
695
845
  <button class="btn" type="submit" id="runFleetBtn"><svg class="ic"><use href="#i-play"/></svg> Run fleet</button>
696
846
  </div>
697
847
  </form>
698
848
  </div>
699
-
700
- <details class="sect" id="libraryBox">
701
- <summary class="sect-label" style="cursor:pointer;list-style:none">Library · design captures ▾</summary>
702
- <div id="captures" style="display:flex;flex-direction:column;gap:6px;margin-top:10px"></div>
703
- <a id="bmk" class="btn-ghost sm" style="text-decoration:none;text-align:center;display:block;padding:6px;margin-top:6px"
704
- title="drag me to your bookmarks bar, then click it on your running app">⌖ coxpit inspect</a>
705
- <span style="font-size:11px;color:var(--faint)">Drag to bookmarks. Click it on your app, then click an element.
706
- With auth on, append ?k=&lt;pass&gt; to the script URL.</span>
707
- </details>
708
- </aside>
849
+ </div>
709
850
  <main>
710
- <div class="toolbar"><button class="btn-ghost sm" id="selToggle">Select runs</button></div>
851
+ <div class="toolbar" style="display:flex;align-items:center;gap:12px">
852
+ <span id="boardHint" style="font-family:var(--mono);font-size:11px;color:var(--faint)"></span>
853
+ <span style="flex:1"></span>
854
+ <button class="btn-ghost sm" id="selToggle">Select runs</button></div>
711
855
  <div class="grid" id="grid"></div>
712
856
  <div class="empty" id="empty">
713
857
  <span class="glyph">▚▞▚</span>
@@ -746,7 +890,7 @@ ${ICON_SPRITE}
746
890
  <div id="outWrap">
747
891
  <div id="outCards"><div class="ocards-empty">loading…</div></div>
748
892
  <div id="outDetail">
749
- <div class="oback" id="outBack">‹ 산출물 목록</div>
893
+ <div class="oback" id="outBack">‹ Outputs</div>
750
894
  <div class="odetail-c" id="outDetailC"></div>
751
895
  </div>
752
896
  </div>
@@ -786,10 +930,10 @@ ${ICON_SPRITE}
786
930
  <div class="chips" id="roomChips"></div>
787
931
  <div class="gbar" id="roomGbar">
788
932
  <span class="selc" id="roomSelC">☑ 0 selected</span>
789
- <button type="button" class="brand" id="roomIntegrateSel">Integrate 선택 (0)</button>
933
+ <button type="button" class="brand" id="roomIntegrateSel">Integrate selected (0)</button>
790
934
  <span class="sp"></span>
791
- <button type="button" id="roomReviewAll">전체 리뷰</button>
792
- <button type="button" class="danger" id="roomGroupClose">그룹 클로즈</button>
935
+ <button type="button" id="roomReviewAll">Review all</button>
936
+ <button type="button" class="danger" id="roomGroupClose">Close group</button>
793
937
  </div>
794
938
  <div class="body">
795
939
  <div class="runs" id="roomRuns"></div>
@@ -982,6 +1126,9 @@ const runs = new Map(); // runId -> run object
982
1126
  const tasks = new Map(); // taskId -> task
983
1127
  const groups = new Map(); // groupId -> {id, kind, title}
984
1128
  let repos = [], machines = [], captures = [];
1129
+ // v5.0 rail — 선택된 repo 로 보드 스코프(client-side). null = All repositories.
1130
+ let selectedRepo = null;
1131
+ try { const s = localStorage.getItem('coxpit.repo'); selectedRepo = s ? Number(s) : null; } catch {}
985
1132
  let daemonPort = 8210; // real config.port — filled from /api/fleet daemon block (recipes interpolate it)
986
1133
  let remoteAuthOpen = false; // true = no password → Funnel guard on (from /api/fleet daemon.authOpen)
987
1134
 
@@ -1255,11 +1402,16 @@ function bandHTML(g, grpRuns){
1255
1402
  + '<button class="gband-fold" data-gfold="'+g.id+'" title="fold">'+(folded?'▸':'▾')+'</button></div>'
1256
1403
  + '<div class="gband-grid">'+cards+'</div></div>';
1257
1404
  }
1405
+ // v5.0 — 레일에서 고른 repo 로 run 을 스코프(client-side). selectedRepo=null → 전체.
1406
+ function runInScope(r){
1407
+ if (selectedRepo==null) return true;
1408
+ const t = tasks.get(r.taskId);
1409
+ return t ? t.repoId===selectedRepo : false;
1410
+ }
1258
1411
  function render(){
1259
1412
  if (boardView==='archive') return; // 아카이브 뷰는 자체 리스트 — grid 안 건드림
1260
- const list = [...runs.values()].sort((a,b)=>b.id-a.id);
1261
- $('empty').style.display = list.length ? 'none' : 'flex';
1262
- if (!list.length){ paintOnboarding(); $('grid').innerHTML=''; return; }
1413
+ const goalsOnly = boardView==='goals'; // Goals 뷰 = 그룹 밴드만
1414
+ const list = [...runs.values()].filter(runInScope).sort((a,b)=>b.id-a.id);
1263
1415
  // 그룹 파티션 — grouped run 은 밴드로 클러스터, ungrouped 는 뒤에 flat.
1264
1416
  const byGroup = new Map(); const flat = [];
1265
1417
  for (const r of list){
@@ -1268,12 +1420,25 @@ function render(){
1268
1420
  if (gid==null) flat.push(r);
1269
1421
  else { if(!byGroup.has(gid)) byGroup.set(gid, []); byGroup.get(gid).push(r); }
1270
1422
  }
1423
+ const visible = goalsOnly ? byGroup.size : list.length;
1424
+ $('empty').style.display = visible ? 'none' : 'flex';
1425
+ renderRail(); // 레일 카운트 배지·attention 을 라이브로 (paintNavCounts·boardHint 포함)
1426
+ if (!visible){ paintOnboarding(goalsOnly); $('grid').innerHTML=''; return; }
1271
1427
  let html = '';
1272
1428
  for (const gid of [...byGroup.keys()].sort((a,b)=>b-a)) html += bandHTML(groups.get(gid), byGroup.get(gid));
1273
- html += flat.map(cardHTML).join('');
1429
+ if (!goalsOnly) html += flat.map(cardHTML).join('');
1274
1430
  $('grid').innerHTML = html;
1431
+ paintBoardHint();
1275
1432
  if (termRunId!=null) termTabsRender(); // 터미널 열려있으면 세션 탭도 동기화
1276
1433
  }
1434
+ // 보드 상단 힌트: <repo> · <view> · N runs (스코프 반영)
1435
+ function paintBoardHint(){
1436
+ const el = $('boardHint'); if(!el) return;
1437
+ const scope = selectedRepo!=null ? (repos.find(r=>r.id===selectedRepo)||{}).name || 'repo' : 'All repositories';
1438
+ const label = boardView==='goals' ? 'Goals' : 'Active';
1439
+ const n = [...runs.values()].filter(runInScope).length;
1440
+ el.textContent = scope + ' · ' + label + ' · ' + n + ' run' + (n===1?'':'s');
1441
+ }
1277
1442
 
1278
1443
  /* ── active / archive view ─────────────────────────────── */
1279
1444
  let boardView = 'active';
@@ -1281,7 +1446,7 @@ let archOffset = 0, archTotal = 0;
1281
1446
  const ARCH_LIMIT = 50;
1282
1447
  function setView(v){
1283
1448
  boardView = v;
1284
- document.querySelectorAll('#viewSeg .seg-opt').forEach(b=>{
1449
+ document.querySelectorAll('#viewNav .navi').forEach(b=>{
1285
1450
  const on = b.dataset.view===v;
1286
1451
  b.classList.toggle('on', on); b.setAttribute('aria-pressed', on?'true':'false');
1287
1452
  });
@@ -1289,11 +1454,19 @@ function setView(v){
1289
1454
  $('archive').hidden = !archive;
1290
1455
  $('grid').style.display = archive ? 'none' : '';
1291
1456
  $('empty').style.display = archive ? 'none' : ($('grid').innerHTML ? 'none' : 'flex');
1292
- document.querySelector('.toolbar').style.display = archive ? 'none' : '';
1293
- if (archive){ paintArchRepos(); archFetch(true); reclaimRefresh(); }
1457
+ document.querySelector('.toolbar').style.display = archive ? 'none' : 'flex';
1458
+ if (archive){ paintArchRepos(); $('archRepo').value = selectedRepo!=null ? String(selectedRepo) : ''; archFetch(true); reclaimRefresh(); }
1294
1459
  else render();
1295
1460
  }
1296
- document.querySelectorAll('#viewSeg .seg-opt').forEach(b=>b.addEventListener('click', ()=>setView(b.dataset.view)));
1461
+ document.querySelectorAll('#viewNav .navi').forEach(b=>b.addEventListener('click', ()=>setView(b.dataset.view)));
1462
+ // 뷰 nav 카운트 — Active=스코프 run 수, Goals=스코프 그룹 수, Archive=닫힌 태스크 수(hydrate)
1463
+ function paintNavCounts(){
1464
+ const scoped = [...runs.values()].filter(runInScope);
1465
+ $('navActiveN').textContent = scoped.length || '';
1466
+ const gset = new Set();
1467
+ for (const r of scoped){ const t = tasks.get(r.taskId); if (t && t.groupId!=null && groups.has(t.groupId)) gset.add(t.groupId); }
1468
+ $('navGoalsN').textContent = gset.size || '';
1469
+ }
1297
1470
  function paintArchRepos(){
1298
1471
  const sel = $('archRepo'); const cur = sel.value;
1299
1472
  sel.innerHTML = '<option value="">all repos</option>' + repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+'</option>').join('');
@@ -1385,7 +1558,15 @@ function chkRow(name, ok, val){
1385
1558
  return '<div class="chk '+cls+'"><span class="st">'+st+'</span><span class="nm">'+esc(name)+'</span>'
1386
1559
  + '<span class="v">'+esc(val||'')+'</span></div>';
1387
1560
  }
1388
- function paintOnboarding(){
1561
+ function paintOnboarding(goalsOnly){
1562
+ // 스코프/뷰 필터 때문에 비어 보이는 경우(전체엔 run 이 있음) → 가벼운 안내만.
1563
+ const totalRuns = runs.size;
1564
+ if (totalRuns && (goalsOnly || selectedRepo!=null)){
1565
+ const msg = goalsOnly ? 'No goal groups here' : 'No runs in this repository';
1566
+ $('empty').innerHTML = '<span class="glyph">▚▞▚</span><span>'+esc(msg)+'</span>'
1567
+ + '<span style="color:#3d4657">'+(selectedRepo!=null?'switch to <b>All repositories</b> or hit + New':'plan a Goal with + New')+'</span>';
1568
+ return;
1569
+ }
1389
1570
  const r = readiness;
1390
1571
  const agentBin = r && r.agent ? r.agent.bin : 'claude';
1391
1572
  let checks;
@@ -1454,7 +1635,7 @@ function upsertRun(patch){
1454
1635
  async function hydrate(){
1455
1636
  const r = await fetch('/api/fleet?view=active').then(x=>x.json());
1456
1637
  machines = r.machines||[]; repos = r.repos||[]; captures = r.captures||[];
1457
- if (r.counts){ const n = r.counts.closedTasks||0; $('archN').textContent = n ? '· '+n : ''; }
1638
+ if (r.counts){ const n = r.counts.closedTasks||0; $('navArchiveN').textContent = n ? String(n) : ''; }
1458
1639
  tasks.clear();
1459
1640
  (r.tasks||[]).forEach(t => tasks.set(t.id, t));
1460
1641
  groups.clear();
@@ -1474,6 +1655,8 @@ async function hydrate(){
1474
1655
  paintSidebar(); render();
1475
1656
  }
1476
1657
  function paintSidebar(){
1658
+ // 스코프 정합 — 사라진 repo 를 물고 있으면 스코프 해제
1659
+ if (selectedRepo!=null && !repos.some(r=>r.id===selectedRepo)){ selectedRepo = null; try{ localStorage.removeItem('coxpit.repo'); }catch{} }
1477
1660
  $('machines').innerHTML = machines.map(m =>
1478
1661
  '<span class="mchip"><span class="mdot '+(m.online?'on':'')+'"></span><b>'+esc(m.slug)+'</b></span>').join('');
1479
1662
  $('repoMachine').innerHTML = machines.map(m=>'<option value="'+esc(m.slug)+'">'+esc(m.slug)+'</option>').join('');
@@ -1481,8 +1664,12 @@ function paintSidebar(){
1481
1664
  $('taskRepo').innerHTML = repos.length
1482
1665
  ? repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+' · '+esc(r.defaultBranch)+'</option>').join('')
1483
1666
  : '<option value="">register a repo — Browse… ↓</option>';
1484
- if (prevRepo && repos.some(r=>String(r.id)===prevRepo)) $('taskRepo').value = prevRepo;
1667
+ // 런처 타겟 기본값 = 스코프된 repo(있으면), 아니면 직전 선택 유지
1668
+ if (selectedRepo!=null && repos.some(r=>r.id===selectedRepo)) $('taskRepo').value = String(selectedRepo);
1669
+ else if (prevRepo && repos.some(r=>String(r.id)===prevRepo)) $('taskRepo').value = prevRepo;
1485
1670
  $('runFleetBtn').disabled = !repos.length;
1671
+ $('newBtn').disabled = !repos.length && false; // New 는 항상 열림(repo 등록 UI 포함)
1672
+ renderRail();
1486
1673
  const capSel = $('taskCapture');
1487
1674
  const cur = capSel.value;
1488
1675
  capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
@@ -1497,34 +1684,104 @@ function paintSidebar(){
1497
1684
  $('bmk').href = "javascript:(function(){var s=document.createElement('script');s.src='"
1498
1685
  + location.origin + "/design/bookmarklet.js';document.body.appendChild(s)})()";
1499
1686
  }
1687
+ /* ── v5.0 navigator rail — machine switcher · repo list(counts+attention+scope) · nav counts ── */
1688
+ const FAILED_STATES = ['failed','error'];
1689
+ function railCounts(){
1690
+ // repoId → { active, attn } — /api/fleet active runs 를 client-side 로 그룹
1691
+ const m = new Map();
1692
+ for (const r of runs.values()){
1693
+ const t = tasks.get(r.taskId); if (!t) continue;
1694
+ const rec = m.get(t.repoId) || { active:0, attn:false };
1695
+ rec.active++;
1696
+ if (FAILED_STATES.includes(r.status)) rec.attn = true;
1697
+ m.set(t.repoId, rec);
1698
+ }
1699
+ return m;
1700
+ }
1701
+ function renderRail(){
1702
+ // machine switcher — 첫 online 머신(없으면 첫 머신) 을 표시, 선택은 #repoMachine 이 보관
1703
+ const cur = $('repoMachine').value || (machines[0] && machines[0].slug) || 'local';
1704
+ const cm = machines.find(x=>x.slug===cur) || machines[0];
1705
+ $('machineLbl').textContent = cm ? cm.slug : 'local';
1706
+ $('machineDot').classList.toggle('on', !!(cm && cm.online));
1707
+ $('machineMenu').innerHTML = machines.map(m=>
1708
+ '<div class="mopt'+(m.slug===cur?' on':'')+'" data-m="'+escA(m.slug)+'">'
1709
+ + '<span class="mdot2'+(m.online?' on':'')+'"></span>'+esc(m.slug)+'</div>').join('')
1710
+ || '<div class="mopt">no machines</div>';
1711
+ // repo list
1712
+ const cnt = railCounts();
1713
+ let html = '';
1714
+ if (repos.length){
1715
+ html += '<button type="button" class="repo'+(selectedRepo==null?' on':'')+'" data-repo="all">'
1716
+ + ic('layers')+'<span class="nm">All repositories</span></button>';
1717
+ for (const r of repos){
1718
+ const c = cnt.get(r.id) || { active:0, attn:false };
1719
+ const on = selectedRepo===r.id;
1720
+ html += '<button type="button" class="repo'+(on?' on':'')+'" data-repo="'+r.id+'" title="'+escA(r.path||r.name)+'">'
1721
+ + ic('folder')+'<span class="nm">'+esc(r.name)+'</span>'
1722
+ + (c.attn ? '<span class="attn" title="a run needs a hand"></span>' : '')
1723
+ + (c.active>0 ? '<span class="cnt">'+c.active+'</span>' : '')
1724
+ + '<span class="rowmenu">'
1725
+ + '<button type="button" class="rmbtn" data-rbranch="'+r.id+'" title="base branch">'+ic('branch')+'</button>'
1726
+ + '<button type="button" class="rmbtn" data-rremove="'+r.id+'" title="remove">'+ic('x')+'</button>'
1727
+ + '</span></button>';
1728
+ }
1729
+ } else {
1730
+ // empty state — 첫 repo 추가 CTA
1731
+ html += '<div style="padding:10px 6px;font-size:12px;color:var(--faint);line-height:1.6">'
1732
+ + 'No repositories yet.<br><b style="color:var(--brand)">Add your first repository</b> to start.</div>';
1733
+ }
1734
+ $('repoList').innerHTML = html;
1735
+ paintNavCounts();
1736
+ paintBoardHint();
1737
+ }
1738
+ // 스코프 설정 — 레일 repo 클릭
1739
+ function setScope(repoId){
1740
+ selectedRepo = repoId;
1741
+ try { if (repoId==null) localStorage.removeItem('coxpit.repo'); else localStorage.setItem('coxpit.repo', String(repoId)); } catch {}
1742
+ if (repoId!=null) $('taskRepo').value = String(repoId), syncSelect('taskRepo');
1743
+ renderRail();
1744
+ if (boardView!=='archive') render();
1745
+ else { paintArchRepos(); $('archRepo').value = repoId!=null ? String(repoId) : ''; syncSelect('archRepo'); archFetch(true); }
1746
+ }
1747
+ $('repoList').addEventListener('click', (e)=>{
1748
+ const br = e.target.closest('button[data-rbranch]');
1749
+ if (br){ e.stopPropagation(); openBranchFor(Number(br.dataset.rbranch)); return; }
1750
+ const rm = e.target.closest('button[data-rremove]');
1751
+ if (rm){ e.stopPropagation(); removeRepoById(Number(rm.dataset.rremove)); return; }
1752
+ const row = e.target.closest('.repo[data-repo]'); if(!row) return;
1753
+ setScope(row.dataset.repo==='all' ? null : Number(row.dataset.repo));
1754
+ });
1500
1755
  $('captures').addEventListener('click', async (e)=>{
1501
1756
  const b = e.target.closest('button[data-delcap]'); if(!b) return;
1502
1757
  await fetch('/api/design/'+b.dataset.delcap,{method:'DELETE'});
1503
1758
  hydrate();
1504
1759
  });
1505
- $('repoRemove').addEventListener('click', async ()=>{
1506
- const rid = $('taskRepo').value;
1760
+ async function removeRepoById(rid){
1507
1761
  if (!rid){ toast('no repository selected', 'error'); return; }
1508
1762
  const yes = await confirmUI('Remove the selected repository from coxpit?',
1509
1763
  { sub: 'The repo itself is untouched — only the registration is removed. Refused while it has open tasks.', danger: true, okLabel: 'Remove' });
1510
1764
  if (!yes) return;
1511
1765
  const res = await fetch('/api/repos/'+rid,{method:'DELETE'});
1512
1766
  const j = await res.json().catch(()=>({}));
1513
- if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
1767
+ if (res.ok){ if (selectedRepo===Number(rid)) setScope(null); toast('repo removed', 'ok'); hydrate(); }
1514
1768
  else toast('remove: '+(j.detail||res.status), 'error');
1515
- });
1769
+ }
1770
+ $('repoRemove').addEventListener('click', ()=>removeRepoById($('taskRepo').value));
1516
1771
  /* ── 기본 브랜치 변경 ── */
1517
- $('repoBranch').addEventListener('click', ()=>{
1518
- const rid = $('taskRepo').value;
1772
+ let brRepoId = null;
1773
+ function openBranchFor(rid){
1519
1774
  if (!rid){ toast('no repository selected', 'error'); return; }
1775
+ brRepoId = rid;
1520
1776
  const repo = repos.find(r=>String(r.id)===String(rid));
1521
1777
  $('brInput').value = repo ? repo.defaultBranch : '';
1522
1778
  $('brOverlay').classList.add('open'); $('brInput').focus();
1523
- });
1779
+ }
1780
+ $('repoBranch').addEventListener('click', ()=>openBranchFor($('taskRepo').value));
1524
1781
  $('brCancel').addEventListener('click', ()=>$('brOverlay').classList.remove('open'));
1525
1782
  $('brOverlay').addEventListener('click',(e)=>{ if(e.target===$('brOverlay')) $('brOverlay').classList.remove('open'); });
1526
1783
  async function brSave(){
1527
- const rid = $('taskRepo').value; const branch = $('brInput').value.trim();
1784
+ const rid = brRepoId; const branch = $('brInput').value.trim();
1528
1785
  if (!rid || !branch) return;
1529
1786
  const res = await fetch('/api/repos/'+rid,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({defaultBranch:branch})});
1530
1787
  const j = await res.json().catch(()=>({}));
@@ -1641,21 +1898,21 @@ function paintModal(){
1641
1898
  answer/doc → mdLite · page → sandbox iframe · code → 기존 diff 렌더러 · file → 이미지/다운로드. */
1642
1899
  let outCards = []; // 이 run 의 마지막 카드 목록(RunOutputCard[])
1643
1900
  const OUT_ICON = { answer:'message', code:'code', doc:'file', page:'image', file:'image' };
1644
- const OUT_LABEL = { answer:'답변', code:'코드', doc:'문서', page:'페이지', file:'파일' };
1901
+ const OUT_LABEL = { answer:'Answer', code:'Code', doc:'Doc', page:'Page', file:'File' };
1645
1902
  function contractHTML(cards){
1646
1903
  const declared = cards.filter(c=>c.required);
1647
1904
  const aux = cards.filter(c=>!c.required && c.present);
1648
1905
  if (!declared.length && !aux.length) return ''; // 계약도 부수도 없으면 스트립 숨김
1649
- let h = '<span class="clabel">요청 산출물(계약):</span>';
1906
+ let h = '<span class="clabel">Required outputs (contract):</span>';
1650
1907
  if (declared.length){
1651
1908
  h += declared.map(c=>{
1652
1909
  const ok = c.present;
1653
1910
  return '<span class="req '+(ok?'ok':'warn')+'"><span class="rg">'+ic(ok?'check':'alert-triangle')+'</span>'
1654
1911
  + esc(OUT_LABEL[c.type]||c.type)+'</span>';
1655
1912
  }).join('');
1656
- } else h += '<span class="req aux">없음</span>';
1913
+ } else h += '<span class="req aux">none</span>';
1657
1914
  if (aux.length){
1658
- h += '<span class="csep">—</span><span class="clabel">부수:</span>'
1915
+ h += '<span class="csep">—</span><span class="clabel">Extra:</span>'
1659
1916
  + aux.map(c=>'<span class="req aux">'+esc(OUT_LABEL[c.type]||c.type)+'</span>').join('');
1660
1917
  }
1661
1918
  return h;
@@ -1663,10 +1920,10 @@ function contractHTML(cards){
1663
1920
  function outCardHTML(c, i){
1664
1921
  const miss = !c.present;
1665
1922
  const badge = c.required
1666
- ? '<span class="obadge '+(c.present?'req':'warn')+'">요청됨</span>'
1667
- : '<span class="obadge">부수</span>';
1923
+ ? '<span class="obadge '+(c.present?'req':'warn')+'">Required</span>'
1924
+ : '<span class="obadge">Extra</span>';
1668
1925
  const gname = miss ? 'alert-triangle' : (OUT_ICON[c.type] || 'circle');
1669
- const meta = miss ? '산출물 미충족 — '+esc(c.meta||'') : esc(c.meta||'');
1926
+ const meta = miss ? 'output not met — '+esc(c.meta||'') : esc(c.meta||'');
1670
1927
  return '<div class="ocard'+(miss?' miss':'')+'" data-oi="'+i+'">'
1671
1928
  + '<span class="og">'+ic(gname)+'</span>'
1672
1929
  + '<span class="ob"><span class="ot">'+esc(c.title||c.type)+'</span>'
@@ -1685,7 +1942,7 @@ function pickDefaultCard(cards){
1685
1942
  if (!hasCode && docish) return cards.indexOf(docish);
1686
1943
  if (hasCode){
1687
1944
  const codeCard = present.find(c=>c.type==='code');
1688
- // 코드 위주라도 선언된 문서/답변이 있으면 계약을 우선(코드는 언제나 카드로 접근 가능)
1945
+ // even when code-heavy, a declared doc/answer wins the contract (code is always reachable via its card)
1689
1946
  const declaredDoc = present.find(c=>c.required && (c.type==='answer'||c.type==='doc'||c.type==='page'));
1690
1947
  if (declaredDoc) return cards.indexOf(declaredDoc);
1691
1948
  return cards.indexOf(codeCard);
@@ -2000,6 +2257,7 @@ async function openCompare(taskId){
2000
2257
  cmpTaskId = taskId;
2001
2258
  cmpDocMode = false; $('cmpDocsTgl').textContent = 'Rendered';
2002
2259
  $('cmpReview').hidden = true; $('cmpReview').innerHTML = '';
2260
+ $('cmpBody').hidden = false; $('cmpAI').textContent = 'AI review'; $('cmpAI').classList.remove('on');
2003
2261
  $('cmpOverlay').classList.add('open');
2004
2262
  $('cmpBody').innerHTML = '<div class="empty" style="flex:1">loading…</div>';
2005
2263
  await paintCompare();
@@ -2090,18 +2348,30 @@ function mdLite(src){
2090
2348
  }
2091
2349
  $('cmpAI').addEventListener('click', async ()=>{
2092
2350
  if (cmpTaskId==null) return;
2093
- const yes = await confirmUI('Run an AI review of these implementations?',
2094
- { sub: 'A reviewer agent reads every diff and summarizes each approach, pros/cons, and a recommendation — so you judge instead of reading all the code. Real agent, spends credits (~1–2 min).', okLabel: 'Review' });
2095
- if (!yes) return;
2096
2351
  const btn = $('cmpAI');
2097
- btn.disabled = true; btn.textContent = 'Reviewing…';
2098
- try{
2099
- const res = await fetch('/api/tasks/'+cmpTaskId+'/review',{method:'POST',
2100
- headers:{'content-type':'application/json'}, body:JSON.stringify({real:true})});
2101
- const j = await res.json().catch(()=>({}));
2102
- if (res.ok){ $('cmpReview').innerHTML = mdLite(j.review||''); $('cmpReview').hidden = false; }
2103
- else toast('review: '+(j.detail||res.status), 'error');
2104
- } finally { btn.disabled = false; btn.textContent = 'AI review'; }
2352
+ // 이미 리뷰가 있으면 → diff 로 토글백
2353
+ if (!$('cmpReview').hidden){
2354
+ $('cmpReview').hidden = true; $('cmpBody').hidden = false;
2355
+ btn.classList.remove('on'); btn.textContent = 'AI review';
2356
+ return;
2357
+ }
2358
+ // 아직 안 불러왔으면 확인 후 리뷰 실행(같은 compare 안에선 1회만)
2359
+ if (!$('cmpReview').innerHTML.trim()){
2360
+ const yes = await confirmUI('Run an AI review of these implementations?',
2361
+ { sub: 'A reviewer agent reads every diff and summarizes each approach, pros/cons, and a recommendation — so you judge instead of reading all the code. Real agent, spends credits (~1–2 min).', okLabel: 'Review' });
2362
+ if (!yes) return;
2363
+ btn.disabled = true; btn.textContent = 'Reviewing…';
2364
+ try{
2365
+ const res = await fetch('/api/tasks/'+cmpTaskId+'/review',{method:'POST',
2366
+ headers:{'content-type':'application/json'}, body:JSON.stringify({real:true})});
2367
+ const j = await res.json().catch(()=>({}));
2368
+ if (!res.ok){ toast('review: '+(j.detail||res.status), 'error'); btn.disabled = false; btn.textContent = 'AI review'; return; }
2369
+ $('cmpReview').innerHTML = mdLite(j.review||'');
2370
+ } finally { btn.disabled = false; }
2371
+ }
2372
+ // 리뷰를 풀높이로, diff 컬럼은 숨김
2373
+ $('cmpReview').hidden = false; $('cmpBody').hidden = true;
2374
+ btn.classList.add('on'); btn.textContent = 'Diffs';
2105
2375
  });
2106
2376
  $('mCompare').addEventListener('click', ()=>{
2107
2377
  if (openRunId==null) return;
@@ -2147,15 +2417,15 @@ function roomRunRowHTML(r){
2147
2417
  const meta = (r.agent?esc(r.agent)+' · ':'') + (r.filesChanged? '+'+r.filesChanged+'f' : 'no changes');
2148
2418
  let acts;
2149
2419
  if (merged){
2150
- acts = '<div class="acts"><button class="b ghost" data-ract="open" data-rrid="'+r.runId+'">열기</button></div>';
2420
+ acts = '<div class="acts"><button class="b ghost" data-ract="open" data-rrid="'+r.runId+'">Open</button></div>';
2151
2421
  } else if (running){
2152
- acts = '<div class="acts"><button class="b ghost" data-ract="open" data-rrid="'+r.runId+'">열기 · 정착 결정</button></div>';
2422
+ acts = '<div class="acts"><button class="b ghost" data-ract="open" data-rrid="'+r.runId+'">Open · decide once settled</button></div>';
2153
2423
  } else {
2154
2424
  acts = '<div class="acts">'
2155
- + '<button class="b ghost" data-ract="review" data-rrid="'+r.runId+'">리뷰</button>'
2156
- + '<button class="b" data-ract="fix" data-rrid="'+r.runId+'">수정</button>'
2157
- + '<button class="b merge" data-ract="merge" data-rrid="'+r.runId+'">머지</button>'
2158
- + '<button class="b close" data-ract="close" data-rrid="'+r.runId+'">클로즈</button>'
2425
+ + '<button class="b ghost" data-ract="review" data-rrid="'+r.runId+'">Review</button>'
2426
+ + '<button class="b" data-ract="fix" data-rrid="'+r.runId+'">Steer</button>'
2427
+ + '<button class="b merge" data-ract="merge" data-rrid="'+r.runId+'">Merge</button>'
2428
+ + '<button class="b close" data-ract="close" data-rrid="'+r.runId+'">Close</button>'
2159
2429
  + '</div>';
2160
2430
  }
2161
2431
  const badge = merged
@@ -2178,10 +2448,10 @@ function roomRunRowHTML(r){
2178
2448
  + '<div class="run-b" data-rbody="'+r.runId+'">'
2179
2449
  + '<div class="rout" data-rout="'+r.runId+'"><div class="rout-cards"><div class="ocards-empty">…</div></div></div>'
2180
2450
  + (roomDone(s) && r.steerable
2181
- ? '<div class="fix"><input data-rfix="'+r.runId+'" placeholder="수정 지시같은 세션·worktree 이어서…" />'
2182
- + '<button class="b" data-ract="fixsend" data-rrid="'+r.runId+'">수정 지시 세션 이어서</button></div>'
2183
- : '<div class="review"><span class="rk">·</span><span class="rt">이 run steer 불가('
2184
- + (running?'아직 실행 중':(roomDone(s)?'세션 없음 — dry/정리됨':'상태 '+esc(s)))+').</span></div>')
2451
+ ? '<div class="fix"><input data-rfix="'+r.runId+'" placeholder="Next instructionsame session &amp; worktree" />'
2452
+ + '<button class="b" data-ract="fixsend" data-rrid="'+r.runId+'">Steercontinue session</button></div>'
2453
+ : '<div class="review"><span class="rk">·</span><span class="rt">This run can\\'t be steered ('
2454
+ + (running?'still running':(roomDone(s)?'no session — dry/cleaned up':'status '+esc(s)))+').</span></div>')
2185
2455
  + '</div></div>';
2186
2456
  }
2187
2457
  function roomRenderRuns(){
@@ -2198,7 +2468,7 @@ function roomRenderRuns(){
2198
2468
  function roomUpdateGbar(){
2199
2469
  const n = roomSel.size;
2200
2470
  $('roomSelC').textContent = '☑ '+n+' selected';
2201
- $('roomIntegrateSel').textContent = 'Integrate 선택 ('+n+')';
2471
+ $('roomIntegrateSel').textContent = 'Integrate selected ('+n+')';
2202
2472
  $('roomIntegrateSel').disabled = n===0;
2203
2473
  }
2204
2474
  /* 펼친 행의 출력 카드 로드 — 모달과 같은 /outputs + renderOutCardInto 재사용(중복 없음). */
@@ -2223,7 +2493,7 @@ function roomOpenCard(rid, i){
2223
2493
  let det = wrap.querySelector('.rout-detail');
2224
2494
  if (!det){
2225
2495
  det = document.createElement('div'); det.className='rout-detail';
2226
- det.innerHTML = '<div class="rout-back">‹ 산출물 목록</div><div class="rout-body"></div>';
2496
+ det.innerHTML = '<div class="rout-back">‹ Outputs</div><div class="rout-body"></div>';
2227
2497
  wrap.appendChild(det);
2228
2498
  }
2229
2499
  det.style.display='';
@@ -2369,7 +2639,7 @@ async function roomRunAction(act, rid){
2369
2639
  if (act==='fix'){ // 펼치고 steer 입력에 포커스
2370
2640
  if (!roomOpen.has(rid)){ roomOpen.add(rid); const row=document.querySelector('.run[data-rrun="'+rid+'"]'); if(row){ row.classList.add('open'); roomLoadRunOutputs(rid); } }
2371
2641
  const inp = document.querySelector('[data-rfix="'+rid+'"]'); if (inp) inp.focus();
2372
- else toast('r'+rid+': steer 불가(정착·세션 필요)', 'error');
2642
+ else toast('r'+rid+': can\\'t steer (needs a settled run with a session)', 'error');
2373
2643
  return;
2374
2644
  }
2375
2645
  if (act==='fixsend'){
@@ -2377,7 +2647,7 @@ async function roomRunAction(act, rid){
2377
2647
  if (!msg){ if(inp) inp.focus(); return; }
2378
2648
  const res = await fetch('/api/runs/'+rid+'/steer',{method:'POST',
2379
2649
  headers:{'content-type':'application/json'}, body:JSON.stringify({message:msg, mode:'work'})});
2380
- if (res.ok){ if(inp) inp.value=''; toast('r'+rid+': 수정 지시 같은 세션 이어서', 'ok'); }
2650
+ if (res.ok){ if(inp) inp.value=''; toast('r'+rid+': steeredcontinuing the same session', 'ok'); }
2381
2651
  else { const j = await res.json().catch(()=>({})); toast('steer: '+(j.detail||res.status), 'error'); }
2382
2652
  return;
2383
2653
  }
@@ -2424,21 +2694,21 @@ async function reviewOneRun(r){
2424
2694
  let rv = body.querySelector('.review.airev');
2425
2695
  if (!rv){ rv = document.createElement('div'); rv.className='review airev'; body.insertBefore(rv, body.querySelector('.fix')||null); }
2426
2696
  const real = $('taskReal').checked;
2427
- rv.innerHTML = '<span class="rk">'+ic('message')+' 리뷰</span><span class="rt">reviewing… ('+(real?'real':'dry')+')</span>';
2697
+ rv.innerHTML = '<span class="rk">'+ic('message')+' Review</span><span class="rt">reviewing… ('+(real?'real':'dry')+')</span>';
2428
2698
  try{
2429
2699
  const res = await fetch('/api/tasks/'+r.taskId+'/review',{method:'POST',
2430
2700
  headers:{'content-type':'application/json'}, body:JSON.stringify({real})});
2431
2701
  const j = await res.json().catch(()=>({}));
2432
- if (res.ok){ rv.innerHTML = '<span class="rk">'+ic('message')+' 리뷰</span><span class="rt">'+mdLite(j.review||'(no summary)')+'</span>'; }
2433
- else { rv.innerHTML = '<span class="rk">'+ic('message')+' 리뷰</span><span class="rt">review 실패 — '+esc(j.detail||String(res.status))+'</span>'; toast('review: '+(j.detail||res.status), 'error'); }
2434
- }catch{ rv.innerHTML = '<span class="rk">'+ic('message')+' 리뷰</span><span class="rt">review 요청 실패</span>'; }
2702
+ if (res.ok){ rv.innerHTML = '<span class="rk">'+ic('message')+' Review</span><span class="rt">'+mdLite(j.review||'(no summary)')+'</span>'; }
2703
+ else { rv.innerHTML = '<span class="rk">'+ic('message')+' Review</span><span class="rt">review failed — '+esc(j.detail||String(res.status))+'</span>'; toast('review: '+(j.detail||res.status), 'error'); }
2704
+ }catch{ rv.innerHTML = '<span class="rk">'+ic('message')+' Review</span><span class="rt">review request failed</span>'; }
2435
2705
  }
2436
2706
  /* steer 입력 Enter → 전송 */
2437
2707
  $('roomRuns').addEventListener('keydown', (e)=>{
2438
2708
  const inp = e.target.closest ? e.target.closest('[data-rfix]') : null;
2439
2709
  if (inp && e.key==='Enter'){ e.preventDefault(); roomRunAction('fixsend', Number(inp.dataset.rfix)); }
2440
2710
  });
2441
- /* ── 그룹 액션 — Integrate 선택 · 전체 리뷰 · 그룹 클로즈(전부 기존 흐름 재사용) ── */
2711
+ /* ── group action bar — Integrate selected · Review all · Close group (all reuse existing flows) ── */
2442
2712
  $('roomIntegrateSel').addEventListener('click', async ()=>{
2443
2713
  if (!roomSel.size){ toast('select settled runs with changes first', 'error'); return; }
2444
2714
  // 기존 select mode + selbar + /api/integrate 재사용 — 방을 닫고 선택을 밴드 select 로 옮긴다.
@@ -2467,7 +2737,7 @@ $('roomReviewAll').addEventListener('click', async ()=>{
2467
2737
  $('roomGroupClose').addEventListener('click', async ()=>{
2468
2738
  if (roomGroupId==null) return;
2469
2739
  const g = roomGroupId;
2470
- await closeGroup(g); // 기존 그룹 클로즈(guard·atRisk 집계 재사용)
2740
+ await closeGroup(g); // reuse existing group close (guard · atRisk tally)
2471
2741
  if (roomGroupId===g) await roomLoad();
2472
2742
  });
2473
2743
  /* + New attempt — 같은 그룹에 새 시도 발사 (현재 dry/real 토글 반영) */
@@ -2699,23 +2969,37 @@ $('mTerm').addEventListener('click', ()=>{
2699
2969
  });
2700
2970
 
2701
2971
  /* ── forms ── */
2702
- /* ── 런처 하나의 시작점: Task | Goal | Workbench ── */
2972
+ /* ── New sheet type seg Task | Goal | Workbench (body + footer adapt, mock's setV) ── */
2703
2973
  let lTab = 'task';
2704
- const L_LABEL = { task: 'Run fleet', goal: 'Plan & fan out', bench: 'Open workbench' };
2974
+ const L_LABEL = { task: 'Run fleet', goal: 'Plan & run', bench: 'Open workbench' };
2975
+ const L_ICON = { task: 'play', goal: 'target', bench: 'terminal' };
2976
+ function setV(tab){
2977
+ lTab = tab;
2978
+ document.querySelectorAll('#launchTabs .seg-opt').forEach(x=>x.classList.toggle('on', x.dataset.tab===tab));
2979
+ $('panelTask').style.display = tab==='task' ? 'flex' : 'none';
2980
+ $('panelGoal').hidden = tab!=='goal';
2981
+ $('panelGoal').style.display = tab==='goal' ? 'flex' : 'none';
2982
+ $('panelBench').hidden = tab!=='bench';
2983
+ $('panelBench').style.display = tab==='bench' ? 'flex' : 'none';
2984
+ // footer adapts: Task=Dry/Real·count·Run fleet · Goal=Dry/Real·Plan & run · Workbench=Open workbench only
2985
+ $('modeSeg').style.display = tab==='bench' ? 'none' : 'flex'; // workbench 는 에이전트 없음
2986
+ $('taskCount').style.display = tab==='task' ? '' : 'none'; // count 는 Task 만(플래너·워크벤치는 1)
2987
+ $('runFleetBtn').innerHTML = ic(L_ICON[tab]) + ' ' + L_LABEL[tab];
2988
+ }
2705
2989
  document.querySelectorAll('#launchTabs .seg-opt').forEach(b=>{
2706
- b.addEventListener('click', ()=>{
2707
- lTab = b.dataset.tab;
2708
- document.querySelectorAll('#launchTabs .seg-opt').forEach(x=>x.classList.toggle('on', x===b));
2709
- $('panelTask').style.display = lTab==='task' ? 'flex' : 'none';
2710
- $('panelGoal').hidden = lTab!=='goal';
2711
- $('panelGoal').style.display = lTab==='goal' ? 'flex' : 'none';
2712
- $('panelBench').hidden = lTab!=='bench';
2713
- $('panelBench').style.display = lTab==='bench' ? 'flex' : 'none';
2714
- $('modeSeg').style.display = lTab==='bench' ? 'none' : 'flex'; // workbench 는 에이전트 없음
2715
- $('taskCount').style.display = lTab==='task' ? '' : 'none';
2716
- $('runFleetBtn').textContent = L_LABEL[lTab];
2717
- });
2990
+ b.addEventListener('click', ()=>setV(b.dataset.tab));
2991
+ });
2992
+
2993
+ /* ── progressive Options reveal (rarely-used Task fields; expanded state remembered per session) ── */
2994
+ function setTaskOpt(open){
2995
+ $('taskOptToggle').setAttribute('aria-expanded', open ? 'true' : 'false');
2996
+ $('taskOptBody').hidden = !open;
2997
+ try{ sessionStorage.setItem('coxpit.taskOpt', open ? '1' : '0'); }catch{}
2998
+ }
2999
+ $('taskOptToggle').addEventListener('click', ()=>{
3000
+ setTaskOpt($('taskOptToggle').getAttribute('aria-expanded') !== 'true');
2718
3001
  });
3002
+ setTaskOpt((()=>{ try{ return sessionStorage.getItem('coxpit.taskOpt')==='1'; }catch{ return false; } })());
2719
3003
 
2720
3004
  async function submitGoal(){
2721
3005
  const repoId = Number($('taskRepo').value);
@@ -2729,7 +3013,7 @@ async function submitGoal(){
2729
3013
  const res = await fetch('/api/plan',{method:'POST',headers:{'content-type':'application/json'},
2730
3014
  body:JSON.stringify({repoId, goal, real})});
2731
3015
  const j = await res.json().catch(()=>({}));
2732
- if (res.ok){ toast(j.tasks.length+' task(s) planned & launched', 'ok'); $('planGoal').value=''; hydrate(); }
3016
+ if (res.ok){ toast(j.tasks.length+' task(s) planned & launched', 'ok'); $('planGoal').value=''; closeLaunch(); hydrate(); }
2733
3017
  else toast('plan: '+(j.detail||j.error||res.status), 'error');
2734
3018
  } finally { btn.disabled = false; btn.textContent = L_LABEL[lTab]; }
2735
3019
  }
@@ -2743,6 +3027,7 @@ async function submitBench(){
2743
3027
  if (!res.ok){ toast('workbench: '+(j.detail||j.error||res.status), 'error'); return; }
2744
3028
  toast('workbench open — terminal attached', 'ok');
2745
3029
  $('benchTitle').value='';
3030
+ closeLaunch();
2746
3031
  await hydrate();
2747
3032
  openTerm(j.runId); // 바로 터미널로
2748
3033
  }
@@ -2782,6 +3067,7 @@ $('taskForm').addEventListener('submit', async (e)=>{
2782
3067
  body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent, model})});
2783
3068
  if (model) rememberModel(model);
2784
3069
  $('taskTitle').value=''; $('taskPrompt').value=''; clearOutputs();
3070
+ closeLaunch();
2785
3071
  });
2786
3072
  /* ── deliverable 계약 칩 — 선택된 타입 배열을 tasks.outputs 로 보낸다(기본=빈=오늘의 동작) ── */
2787
3073
  const OUTPUT_ORDER = ['answer','code','doc','page','file'];
@@ -2830,7 +3116,7 @@ setMode(savedMode === '1', false);
2830
3116
 
2831
3117
  /* ── provider segmented control — Task 탭 전용(Goal 플래너·Workbench 는 무관) ── */
2832
3118
  let selAgent = 'claude-code';
2833
- const provOpts = Array.from(document.querySelectorAll('#provSeg .seg-opt'));
3119
+ const provOpts = Array.from(document.querySelectorAll('#provSeg .seg-opt, #provSegGoal .seg-opt'));
2834
3120
  function setProvider(id, persist){
2835
3121
  selAgent = id;
2836
3122
  for (const b of provOpts){
@@ -3069,6 +3355,43 @@ function setDrawer(on){ asideEl.classList.toggle('open', on); $('scrim').classLi
3069
3355
  $('menuBtn').addEventListener('click', ()=>setDrawer(!asideEl.classList.contains('open')));
3070
3356
  $('scrim').addEventListener('click', ()=>setDrawer(false));
3071
3357
 
3358
+ /* ── v5.0 rail interactions — machine switcher · New sheet · Add repository ── */
3359
+ function openLaunch(tab){
3360
+ const r = repos.find(x=>x.id===selectedRepo);
3361
+ $('sheetRepoLbl').textContent = r ? ('· '+r.name) : (repos.length ? '· all repositories' : '· register a repo first');
3362
+ setV(tab || 'task'); // + New 는 Task 로 열림; 타입 seg 가 안에서 전환
3363
+ $('newSheet').classList.add('open');
3364
+ setDrawer(false);
3365
+ }
3366
+ function closeLaunch(){ $('newSheet').classList.remove('open'); $('repoActions').hidden = true; $('repoForm').hidden = true; }
3367
+ $('newBtn').addEventListener('click', ()=>openLaunch('task'));
3368
+ $('fab').addEventListener('click', ()=>openLaunch('task')); // mobile pocket-board FAB → same sheet
3369
+ $('sheetClose').addEventListener('click', closeLaunch);
3370
+ $('newSheet').addEventListener('click', (e)=>{ if(e.target===$('newSheet')) closeLaunch(); });
3371
+ // Add repository — 시트를 열고 repo 피커(Browse/New-folder/Path 내장)를 노출
3372
+ $('repoAdd').addEventListener('click', ()=>{ openLaunch('task'); $('repoActions').hidden = false; $('repoBrowse').click(); });
3373
+ // machine switcher — 컴팩트 버튼 + 메뉴(기존 #repoMachine 이 선택 상태 보관)
3374
+ function positionMachineMenu(){
3375
+ const b = $('machineSwitch').getBoundingClientRect();
3376
+ const mm = $('machineMenu');
3377
+ mm.style.left = b.left + 'px';
3378
+ mm.style.top = (b.bottom + 4) + 'px';
3379
+ }
3380
+ $('machineSwitch').addEventListener('click', (e)=>{
3381
+ e.stopPropagation();
3382
+ const mm = $('machineMenu');
3383
+ const was = mm.classList.contains('open');
3384
+ closeDropdowns(); mm.classList.remove('open');
3385
+ if (!was){ positionMachineMenu(); mm.classList.add('open'); }
3386
+ });
3387
+ $('machineMenu').addEventListener('click', (e)=>{
3388
+ const o = e.target.closest('.mopt[data-m]'); if(!o) return;
3389
+ $('repoMachine').value = o.dataset.m; syncSelect('repoMachine');
3390
+ $('machineMenu').classList.remove('open');
3391
+ renderRail();
3392
+ });
3393
+ document.addEventListener('click', ()=>$('machineMenu').classList.remove('open'));
3394
+
3072
3395
  /* 딥링크 — /?run=N 이면 하이드레이션 후 그 run 모달을 연다 (웹훅 링크·알림용) */
3073
3396
  function openFromURL(){
3074
3397
  const q = new URLSearchParams(location.search).get('run');
package/src/icons.ts CHANGED
@@ -31,6 +31,10 @@ export const ICON_SPRITE = `<svg style="position:absolute;width:0;height:0" aria
31
31
  <symbol id="i-bell-off" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .258-1.742"/><path d="m2 2 20 20"/><path d="M8.668 3.01A6 6 0 0 1 18 8c0 2.687.77 4.653 1.707 6.05"/></symbol>
32
32
  <symbol id="i-download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol>
33
33
  <symbol id="i-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></symbol>
34
+ <symbol id="i-server" viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/></symbol>
35
+ <symbol id="i-layers" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol>
36
+ <symbol id="i-target" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></symbol>
37
+ <symbol id="i-archive" viewBox="0 0 24 24"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></symbol>
34
38
  </svg>`;
35
39
 
36
40
  /** .ic 아이콘 스타일(1em·currentColor 상속·stroke). board/login 공통. */
package/src/login.ts CHANGED
@@ -1,11 +1,25 @@
1
1
  // 브랜디드 접근키 언락/셋업 페이지(단일 자가완결 HTML — 빌드 0, 보드 토큰 매치).
2
- // 브라우저 basic-auth 팝업을 대체한다. fetch 로 A2/A3 엔드포인트 POST → 성공 시 보드로 reload.
2
+ // 브라우저 basic-auth 팝업을 대체한다.
3
+ // ── Safari 쿠키 레이스 수정(v5.0) ──
4
+ // 폼은 real navigation POST(urlencoded, hidden nav=1)로 /api/auth/{unlock,setup} 에 제출한다.
5
+ // 서버는 성공 시 303 → GET /(브라우저가 그 응답의 Set-Cookie 를 커밋한 뒤 이동)로 답하므로,
6
+ // fetch-then-location.replace 가 겪던 "쿠키 미커밋 → 다시 로그인" 레이스가 사라진다.
7
+ // 실패는 서버가 이 페이지를 error 와 함께 다시 렌더한다(JS 없이도 동작). JSON API 는 그대로 유지.
3
8
  // setup=true → 첫 실행(키 설정, confirm 필드+토큰 힌트), false → 언락(키 1개).
4
9
  import { ICON_SPRITE, ICON_CSS } from './icons.js';
5
10
 
6
- /** login/setup 페이지 HTML. setup 이면 셋업(키+확인), 아니면 언락. */
7
- export function loginPageHTML(setup: boolean): string {
11
+ /** HTML 속성 이스케이프(서버 렌더 에러 문자열 안전 주입용). */
12
+ function escA(s: string): string {
13
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
14
+ }
15
+
16
+ export interface LoginOpts { error?: string }
17
+
18
+ /** login/setup 페이지 HTML. setup 이면 셋업(키+확인), 아니면 언락. opts.error 는 서버가 재렌더 시 주입. */
19
+ export function loginPageHTML(setup: boolean, opts: LoginOpts = {}): string {
8
20
  const title = setup ? 'set an access key' : 'unlock';
21
+ const initialErr = opts.error ? escA(opts.error) : '';
22
+ const action = setup ? '/api/auth/setup' : '/api/auth/unlock';
9
23
  return /* html */ `<!doctype html>
10
24
  <html lang="en">
11
25
  <head>
@@ -25,8 +39,11 @@ export function loginPageHTML(setup: boolean): string {
25
39
  *{box-sizing:border-box}
26
40
  [hidden]{display:none !important}
27
41
  html,body{height:100%}
42
+ html{overscroll-behavior:none}
28
43
  body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
29
- -webkit-font-smoothing:antialiased;display:flex;align-items:center;justify-content:center;padding:24px}
44
+ -webkit-font-smoothing:antialiased;display:flex;align-items:center;justify-content:center;
45
+ padding:24px;padding:max(24px,env(safe-area-inset-top)) max(24px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(24px,env(safe-area-inset-left));
46
+ overflow-x:hidden}
30
47
  ::selection{background:var(--brand-dim)}
31
48
  .card{width:100%;max-width:380px;background:var(--surface);border:1px solid var(--line);
32
49
  border-radius:var(--r-card);box-shadow:var(--shadow);padding:26px 24px 22px}
@@ -38,7 +55,7 @@ export function loginPageHTML(setup: boolean): string {
38
55
  .flabel{display:block;font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.12em;
39
56
  color:var(--faint);margin:0 0 6px}
40
57
  input[type=password]{width:100%;background:#0e1118;border:1px solid var(--line);border-radius:var(--r-ctl);
41
- color:var(--ink);font-family:var(--mono);font-size:14px;padding:10px 12px;outline:none}
58
+ color:var(--ink);font-family:var(--mono);font-size:16px;padding:10px 12px;outline:none}
42
59
  input[type=password]:focus{border-color:var(--brand)}
43
60
  input::placeholder{color:var(--faint)}
44
61
  .fld{margin-bottom:14px}
@@ -62,7 +79,8 @@ export function loginPageHTML(setup: boolean): string {
62
79
  </head>
63
80
  <body>
64
81
  ${ICON_SPRITE}
65
- <form class="card" id="f" autocomplete="off">
82
+ <form class="card" id="f" method="post" action="${action}" autocomplete="off">
83
+ <input type="hidden" name="nav" value="1">
66
84
  <div class="mark"><svg class="ic"><use href="#i-lock"/></svg><span>coxpit</span></div>
67
85
  <h1>${setup ? 'Protect this coxpit' : 'Unlock this coxpit'}</h1>
68
86
  <p class="sub">${setup
@@ -71,14 +89,18 @@ ${ICON_SPRITE}
71
89
  ${setup ? `<div class="hint">To prove you own this machine, this first-time setup needs the one-time
72
90
  <code>setup token</code> printed in the daemon log &mdash; unless you&#39;re on
73
91
  <code>http://127.0.0.1</code> directly. Paste it below if asked.</div>` : ''}
74
- <div class="err" id="err"></div>
92
+ <div class="err" id="err">${initialErr}</div>
75
93
  ${setup ? `<div class="fld"><label class="flabel" for="tok">setup token (from the daemon log)</label>
76
- <input id="tok" type="password" placeholder="paste if not on localhost" autocomplete="off"></div>` : ''}
94
+ <input id="tok" name="token" type="password" placeholder="paste if not on localhost" autocomplete="off"
95
+ autocapitalize="none" autocorrect="off" spellcheck="false"></div>` : ''}
77
96
  <div class="fld"><label class="flabel" for="key">access key</label>
78
- <input id="key" type="password" placeholder="&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;" autocomplete="${setup ? 'new-password' : 'current-password'}" autofocus></div>
97
+ <input id="key" name="key" type="password" placeholder="&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;"
98
+ autocomplete="${setup ? 'new-password' : 'current-password'}"
99
+ autocapitalize="none" autocorrect="off" spellcheck="false" autofocus></div>
79
100
  ${setup ? `<div class="fld"><label class="flabel" for="key2">confirm access key</label>
80
- <input id="key2" type="password" placeholder="&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;" autocomplete="new-password"></div>` : ''}
81
- <label class="row"><input id="rem" type="checkbox"${setup ? ' checked' : ''}><span>Remember this device</span></label>
101
+ <input id="key2" type="password" placeholder="&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;"
102
+ autocomplete="new-password" autocapitalize="none" autocorrect="off" spellcheck="false"></div>` : ''}
103
+ <label class="row"><input id="rem" name="remember" type="checkbox" value="on"${setup ? ' checked' : ''}><span>Remember this device</span></label>
82
104
  <button class="btn" id="go" type="submit"><svg class="ic"><use href="#i-${setup ? 'lock' : 'unlock'}"/></svg><span>${setup ? 'Set key &amp; enter' : 'Unlock'}</span></button>
83
105
  <div class="ft"><a href="https://github.com/hanmariyang/coxpit-oss#remote-access" target="_blank" rel="noopener">Fronting with Cloudflare Access / Tailscale? &rarr;</a></div>
84
106
  </form>
@@ -89,33 +111,18 @@ ${ICON_SPRITE}
89
111
  var err = document.getElementById('err');
90
112
  var go = document.getElementById('go');
91
113
  function show(m){ err.textContent = m || ''; }
114
+ // 폼은 native navigation POST 로 제출된다(Safari 가 응답의 Set-Cookie 를 커밋 → 303 GET /).
115
+ // 여기서는 클라이언트 사전검증만: 비면 막고, setup 이면 confirm/길이 확인. 통과 시 native 제출 진행.
92
116
  f.addEventListener('submit', function(ev){
93
- ev.preventDefault();
94
- show('');
95
117
  var key = document.getElementById('key').value;
96
- var rem = document.getElementById('rem').checked;
97
- if (!key){ show('access key required'); return; }
98
- var body, url;
118
+ if (!key){ ev.preventDefault(); show('access key required'); return; }
99
119
  if (SETUP){
100
120
  var key2 = document.getElementById('key2').value;
101
- if (key !== key2){ show('keys do not match'); return; }
102
- if (key.length < 6){ show('use at least 6 characters'); return; }
103
- url = '/api/auth/setup';
104
- body = { key: key, token: document.getElementById('tok').value, remember: rem };
105
- } else {
106
- url = '/api/auth/unlock';
107
- body = { key: key, remember: rem };
121
+ if (key !== key2){ ev.preventDefault(); show('keys do not match'); return; }
122
+ if (key.length < 6){ ev.preventDefault(); show('use at least 6 characters'); return; }
108
123
  }
109
- go.disabled = true;
110
- fetch(url, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) })
111
- .then(function(r){ return r.json().then(function(j){ return { s:r.status, j:j }; }); })
112
- .then(function(res){
113
- if (res.s >= 200 && res.s < 300 && res.j && res.j.ok){ location.replace('/'); return; }
114
- go.disabled = false;
115
- var m = (res.j && (res.j.detail || res.j.error)) || ('error ' + res.s);
116
- show(m);
117
- })
118
- .catch(function(){ go.disabled = false; show('network error'); });
124
+ show('');
125
+ go.disabled = true; // 네비게이션 진행 중 중복 제출 방지(네이티브 제출은 계속)
119
126
  });
120
127
  })();
121
128
  </script>
package/src/server.ts CHANGED
@@ -8,6 +8,7 @@ import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
8
8
  import websocket from '@fastify/websocket';
9
9
  import { eq, inArray, and, like, desc } from 'drizzle-orm';
10
10
  import { authGate } from './auth';
11
+ import { loginPageHTML } from './login';
11
12
  import {
12
13
  authMode, authIsOpen, verifyKey, storeKey, signSession, SESSION_COOKIE,
13
14
  clientKey, rateCheck, rateFail, rateReset, setupAllowed,
@@ -179,6 +180,20 @@ function sharePageHTML(
179
180
  export async function buildServer(): Promise<FastifyInstance> {
180
181
  const app = Fastify({ logger: true });
181
182
  await app.register(websocket);
183
+ // urlencoded 본문 파서(deps 0) — login/setup 폼이 real navigation POST 를 보내면
184
+ // 브라우저가 그 응답의 Set-Cookie 를 확정 커밋한다(Safari fetch-then-replace 레이스 회피).
185
+ app.addContentTypeParser(
186
+ 'application/x-www-form-urlencoded',
187
+ { parseAs: 'string' },
188
+ (_req, body, done) => {
189
+ try {
190
+ const out: Record<string, string> = {};
191
+ const p = new URLSearchParams(body as string);
192
+ for (const [k, v] of p.entries()) out[k] = v;
193
+ done(null, out);
194
+ } catch (e) { done(e as Error, undefined); }
195
+ },
196
+ );
182
197
  app.addHook('onRequest', authGate);
183
198
 
184
199
  // 무인증 헬스(외부 감시용)
@@ -210,45 +225,71 @@ export async function buildServer(): Promise<FastifyInstance> {
210
225
  };
211
226
  const socketIp = (req: { socket?: { remoteAddress?: string } }): string =>
212
227
  String(req.socket?.remoteAddress ?? '');
228
+ // real form navigation POST(urlencoded + nav flag) 인가 — 그러면 JSON 대신 303/HTML 로 답한다.
229
+ // 이 경로는 브라우저가 응답의 Set-Cookie 를 커밋한 뒤 GET / 로 이동하므로 Safari 쿠키 레이스가 없다.
230
+ const isFormNav = (req: { body?: unknown; headers: Record<string, unknown> }): boolean => {
231
+ const ct = String(req.headers['content-type'] ?? '');
232
+ const b = (req.body ?? {}) as { nav?: unknown };
233
+ return ct.includes('application/x-www-form-urlencoded') || b.nav != null;
234
+ };
235
+ const truthy = (v: unknown): boolean =>
236
+ v === true || v === 'true' || v === 'on' || v === '1' || v === 1;
213
237
 
214
238
  // 첫 실행 셋업(anti-claim) — 셋업 토큰 일치 OR 진짜 로컬(loopback+no-fwd)만 허용.
215
239
  // 키가 이미 있으면 409(단발). 성공 시 해시 저장 + 세션 쿠키.
216
240
  app.post('/api/auth/setup', async (req, reply) => {
217
- if (authMode().mode !== 'setup') return reply.code(409).send({ error: 'already configured' });
218
- const b = (req.body ?? {}) as { key?: string; token?: string; remember?: boolean };
241
+ const nav = isFormNav(req);
242
+ // form-nav 실패는 로그인 페이지를 에러와 함께 다시 렌더(JSON 자동화는 아래처럼 JSON 유지).
243
+ const fail = (code: number, detail: string): FastifyReply => {
244
+ if (nav) return reply.type('text/html').code(code).send(loginPageHTML(true, { error: detail }));
245
+ return reply.code(code).send({ error: 'setup', detail });
246
+ };
247
+ if (authMode().mode !== 'setup') return fail(409, 'already configured');
248
+ const b = (req.body ?? {}) as { key?: string; token?: string; remember?: unknown };
219
249
  const key = String(b.key ?? '');
220
- if (key.length < 6) return reply.code(400).send({ error: 'key too short', detail: 'use at least 6 characters' });
250
+ if (key.length < 6) return fail(400, 'use at least 6 characters');
221
251
  const gate = setupAllowed(req.headers as Record<string, unknown>, socketIp(req), String(b.token ?? ''));
222
252
  if (!gate.ok) {
223
- return reply.code(403).send({ error: 'setup not allowed', detail: 'paste the one-time setup token from the daemon log (this request is not local)' });
253
+ return fail(403, 'paste the one-time setup token from the daemon log (this request is not local)');
224
254
  }
225
255
  storeKey(key); // 평문 키는 절대 로그하지 않음
226
- setSessionCookie(reply, req, b.remember === true);
256
+ setSessionCookie(reply, req, truthy(b.remember));
257
+ // form-nav → 303 redirect to /(브라우저가 Set-Cookie 커밋 후 이동). JSON → 201.
258
+ if (nav) return reply.code(303).header('location', '/').send();
227
259
  return reply.code(201).send({ ok: true });
228
260
  });
229
261
 
230
262
  // 언락 — 상수시간 검증 + per-client 레이트리밋(백오프). 성공 시 세션 쿠키.
231
263
  app.post('/api/auth/unlock', async (req, reply) => {
264
+ const nav = isFormNav(req);
265
+ const fail = (code: number, detail: string): FastifyReply => {
266
+ if (nav) return reply.type('text/html').code(code).send(loginPageHTML(false, { error: detail }));
267
+ return reply.code(code).send({ error: 'unlock', detail });
268
+ };
232
269
  const m = authMode();
233
- if (m.mode === 'disabled') return reply.send({ ok: true });
234
- if (m.mode === 'setup') return reply.code(409).send({ error: 'not configured', detail: 'set an access key first' });
270
+ if (m.mode === 'disabled') {
271
+ if (nav) return reply.code(303).header('location', '/').send();
272
+ return reply.send({ ok: true });
273
+ }
274
+ if (m.mode === 'setup') return fail(409, 'set an access key first');
235
275
  const id = clientKey(req.headers as Record<string, unknown>, socketIp(req));
236
276
  const rc = rateCheck(id);
237
277
  if (rc.blocked) {
238
278
  const secs = Math.ceil(rc.retryMs / 1000);
239
- return reply.code(429).send({ error: 'too many attempts', detail: `try again in ${secs}s` });
279
+ return fail(429, `too many attempts try again in ${secs}s`);
240
280
  }
241
- const b = (req.body ?? {}) as { key?: string; remember?: boolean };
281
+ const b = (req.body ?? {}) as { key?: string; remember?: unknown };
242
282
  if (verifyKey(String(b.key ?? ''), m)) {
243
283
  rateReset(id);
244
- setSessionCookie(reply, req, b.remember === true);
284
+ setSessionCookie(reply, req, truthy(b.remember));
285
+ if (nav) return reply.code(303).header('location', '/').send();
245
286
  return reply.send({ ok: true });
246
287
  }
247
288
  const after = rateFail(id);
248
289
  const detail = after.retryMs > 0
249
290
  ? `wrong key — try again in ${Math.ceil(after.retryMs / 1000)}s`
250
291
  : `wrong key — ${after.attemptsLeft} attempt(s) left`;
251
- return reply.code(401).send({ error: 'wrong key', detail });
292
+ return fail(401, detail);
252
293
  });
253
294
 
254
295
  // 로그아웃 — 쿠키 제거(Max-Age=0).