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 +1 -1
- package/src/board.ts +470 -147
- package/src/icons.ts +4 -0
- package/src/login.ts +40 -33
- package/src/server.ts +52 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "
|
|
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{
|
|
482
|
-
max-
|
|
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
|
|
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
|
-
<
|
|
631
|
-
<
|
|
632
|
-
<
|
|
633
|
-
<
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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=<pass> 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
|
|
660
|
-
<
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
<
|
|
665
|
-
|
|
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
|
-
<
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
<
|
|
676
|
-
<
|
|
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
|
|
682
|
-
<
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
<
|
|
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=<pass> to the script URL.</span>
|
|
707
|
-
</details>
|
|
708
|
-
</aside>
|
|
849
|
+
</div>
|
|
709
850
|
<main>
|
|
710
|
-
<div class="toolbar"
|
|
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">‹
|
|
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
|
|
933
|
+
<button type="button" class="brand" id="roomIntegrateSel">Integrate selected (0)</button>
|
|
790
934
|
<span class="sp"></span>
|
|
791
|
-
<button type="button" id="roomReviewAll"
|
|
792
|
-
<button type="button" class="danger" id="roomGroupClose"
|
|
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
|
|
1261
|
-
|
|
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('#
|
|
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('#
|
|
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; $('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1518
|
-
|
|
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 =
|
|
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:'
|
|
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"
|
|
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"
|
|
1913
|
+
} else h += '<span class="req aux">none</span>';
|
|
1657
1914
|
if (aux.length){
|
|
1658
|
-
h += '<span class="csep">—</span><span class="clabel"
|
|
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')+'"
|
|
1667
|
-
: '<span class="obadge"
|
|
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 ? '
|
|
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
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
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+'"
|
|
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+'"
|
|
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+'"
|
|
2156
|
-
+ '<button class="b" data-ract="fix" data-rrid="'+r.runId+'"
|
|
2157
|
-
+ '<button class="b merge" data-ract="merge" data-rrid="'+r.runId+'"
|
|
2158
|
-
+ '<button class="b close" data-ract="close" data-rrid="'+r.runId+'"
|
|
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="
|
|
2182
|
-
+ '<button class="b" data-ract="fixsend" data-rrid="'+r.runId+'"
|
|
2183
|
-
: '<div class="review"><span class="rk">·</span><span class="rt"
|
|
2184
|
-
+ (running?'
|
|
2451
|
+
? '<div class="fix"><input data-rfix="'+r.runId+'" placeholder="Next instruction — same session & worktree…" />'
|
|
2452
|
+
+ '<button class="b" data-ract="fixsend" data-rrid="'+r.runId+'">Steer → continue 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
|
|
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">‹
|
|
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
|
|
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+':
|
|
2650
|
+
if (res.ok){ if(inp) inp.value=''; toast('r'+rid+': steered — continuing 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')+'
|
|
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')+'
|
|
2433
|
-
else { rv.innerHTML = '<span class="rk">'+ic('message')+'
|
|
2434
|
-
}catch{ rv.innerHTML = '<span class="rk">'+ic('message')+'
|
|
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
|
-
/* ──
|
|
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); //
|
|
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
|
-
/* ──
|
|
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 &
|
|
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
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
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 팝업을 대체한다.
|
|
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
|
-
/**
|
|
7
|
-
|
|
11
|
+
/** HTML 속성 값 이스케이프(서버 렌더 에러 문자열 안전 주입용). */
|
|
12
|
+
function escA(s: string): string {
|
|
13
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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;
|
|
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:
|
|
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 — unless you're on
|
|
73
91
|
<code>http://127.0.0.1</code> directly. Paste it below if asked.</div>` : ''}
|
|
74
|
-
<div class="err" id="err"
|
|
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"
|
|
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="••••••••"
|
|
97
|
+
<input id="key" name="key" type="password" placeholder="••••••••"
|
|
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="••••••••"
|
|
81
|
-
|
|
101
|
+
<input id="key2" type="password" placeholder="••••••••"
|
|
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 & 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? →</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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
|
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
|
|
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
|
|
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')
|
|
234
|
-
|
|
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
|
|
279
|
+
return fail(429, `too many attempts — try again in ${secs}s`);
|
|
240
280
|
}
|
|
241
|
-
const b = (req.body ?? {}) as { key?: string; remember?:
|
|
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
|
|
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
|
|
292
|
+
return fail(401, detail);
|
|
252
293
|
});
|
|
253
294
|
|
|
254
295
|
// 로그아웃 — 쿠키 제거(Max-Age=0).
|