coxpit 4.3.1 → 4.6.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/README.md +13 -1
- package/package.json +1 -1
- package/src/board.ts +672 -12
- package/src/db/index.ts +1 -0
- package/src/db/schema.ts +1 -0
- package/src/orchestrator.ts +136 -7
- package/src/providers.ts +14 -5
- package/src/remote.ts +148 -0
- package/src/server.ts +215 -7
package/src/board.ts
CHANGED
|
@@ -152,6 +152,56 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
152
152
|
.gband.folded .gband-grid{display:none}
|
|
153
153
|
.gband-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
|
|
154
154
|
.attempt{color:var(--brand);opacity:.8}
|
|
155
|
+
|
|
156
|
+
/* ── goal workroom (v4.6) — 한 goal 을 여는 단일 방 ── */
|
|
157
|
+
.gband-open{margin-left:2px}
|
|
158
|
+
.room{width:min(920px,96vw);max-height:90vh;background:var(--surface);border:1px solid var(--line);
|
|
159
|
+
border-radius:16px;display:flex;flex-direction:column;overflow:hidden;box-shadow:var(--shadow)}
|
|
160
|
+
.rh{display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--line);
|
|
161
|
+
background:linear-gradient(180deg,var(--brand-dim),transparent)}
|
|
162
|
+
.rh .rh-glyph{color:var(--brand);font-family:var(--mono);font-size:15px}
|
|
163
|
+
.rh .rh-t{font-weight:600;font-size:14px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
164
|
+
.rh .rh-n{font-family:var(--mono);font-size:11px;color:var(--faint)}
|
|
165
|
+
.rh .x{background:transparent;border:none;color:var(--muted);font-size:19px;cursor:pointer;
|
|
166
|
+
padding:2px 9px;border-radius:6px;line-height:1}
|
|
167
|
+
.rh .x:hover{color:var(--ink);background:var(--surface2)}
|
|
168
|
+
.chips{display:flex;flex-wrap:wrap;gap:7px;padding:12px 18px;border-bottom:1px solid var(--line)}
|
|
169
|
+
.room .chip{cursor:pointer;transition:border-color .15s,filter .15s}
|
|
170
|
+
.room .chip:hover{filter:brightness(1.15)}
|
|
171
|
+
.room .chip .cid{font-weight:600}
|
|
172
|
+
.room .chip .cmeta{opacity:.7;text-transform:none;letter-spacing:0}
|
|
173
|
+
.room .chip.new{border-style:dashed}
|
|
174
|
+
.body{overflow:auto;flex:1;min-height:120px}
|
|
175
|
+
.feed{display:flex;flex-direction:column;gap:6px;padding:12px 18px;font-family:var(--mono);font-size:11.5px}
|
|
176
|
+
.feed .fl{display:flex;gap:9px;align-items:baseline;min-width:0}
|
|
177
|
+
.feed .fl .who{min-width:52px;flex:none;opacity:.9}
|
|
178
|
+
.feed .fl .ft{color:var(--muted);word-break:break-word;flex:1}
|
|
179
|
+
.feed .fl.mark .who{color:var(--brand)}
|
|
180
|
+
.feed .empty-note{color:var(--faint)}
|
|
181
|
+
.conv{display:flex;flex-direction:column;gap:12px;padding:12px 18px;font-size:12.5px}
|
|
182
|
+
.conv .empty-note{color:var(--faint);font-family:var(--mono);font-size:11.5px}
|
|
183
|
+
.conv .turn{display:flex;flex-direction:column;gap:3px}
|
|
184
|
+
.conv .turn .role{font-family:var(--mono);font-size:9.5px;font-weight:600;text-transform:uppercase;letter-spacing:.06em}
|
|
185
|
+
.conv .turn.you .role{color:var(--muted)}
|
|
186
|
+
.conv .turn.coord .role{color:var(--brand)}
|
|
187
|
+
.conv .turn .msg{color:var(--ink);white-space:pre-wrap;word-break:break-word;line-height:1.55}
|
|
188
|
+
.conv .turn.you .msg{color:var(--muted)}
|
|
189
|
+
.conv .turn .ro-note{font-family:var(--mono);font-size:9.5px;color:var(--faint);letter-spacing:.02em}
|
|
190
|
+
.send-only{display:flex;gap:8px;align-items:center}
|
|
191
|
+
.send-only .ro-hint{flex:1;font-family:var(--mono);font-size:10px;color:var(--faint);letter-spacing:.02em}
|
|
192
|
+
.composer{border-top:1px solid var(--line);padding:12px 18px;display:flex;flex-direction:column;gap:8px}
|
|
193
|
+
.composer .comp-seg{flex:0 0 auto}
|
|
194
|
+
.composer .comp-hint{font-family:var(--mono);font-size:10.5px;color:var(--faint);letter-spacing:.02em}
|
|
195
|
+
.composer textarea{min-height:52px}
|
|
196
|
+
.verbs{display:flex;gap:8px;align-items:center}
|
|
197
|
+
.verbs .grow{flex:1}
|
|
198
|
+
.conv-menu{position:relative}
|
|
199
|
+
.conv-pop{position:absolute;bottom:calc(100% + 6px);right:0;z-index:5;background:var(--surface2);
|
|
200
|
+
border:1px solid var(--line-hi);border-radius:9px;box-shadow:var(--shadow);padding:4px;min-width:200px;display:none}
|
|
201
|
+
.conv-menu.open .conv-pop{display:block}
|
|
202
|
+
.conv-pop button{display:block;width:100%;text-align:left;background:transparent;border:none;
|
|
203
|
+
color:var(--muted);font-size:12.5px;padding:8px 10px;border-radius:6px;cursor:pointer}
|
|
204
|
+
.conv-pop button:hover{background:var(--surface);color:var(--ink)}
|
|
155
205
|
/* ── active/archive view seg + archive list ── */
|
|
156
206
|
.view-seg{padding:2px;gap:2px}
|
|
157
207
|
.view-seg .seg-opt{padding:4px 12px;font-size:11.5px}
|
|
@@ -241,6 +291,52 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
241
291
|
.setup-steps li{margin-bottom:7px}
|
|
242
292
|
.setup-steps b{color:var(--ink)}
|
|
243
293
|
|
|
294
|
+
/* ── remote access card (v4.5) ──────────── */
|
|
295
|
+
.rmt{font-size:13px;color:var(--muted)}
|
|
296
|
+
.rmt-line{margin:2px 0 10px;line-height:1.55}
|
|
297
|
+
.rmt-name{font-family:var(--mono);font-size:12px;color:var(--ink);background:#0e1118;
|
|
298
|
+
border:1px solid var(--line);border-radius:6px;padding:2px 7px;word-break:break-all}
|
|
299
|
+
.rmt-row{display:flex;align-items:center;gap:10px;padding:9px 0;border-top:1px solid var(--line)}
|
|
300
|
+
.rmt-row .rmt-l{flex:1;min-width:0}
|
|
301
|
+
.rmt-row .rmt-t{color:var(--ink);font-weight:600;font-size:13px}
|
|
302
|
+
.rmt-row .rmt-d{color:var(--faint);font-size:11.5px;margin-top:2px}
|
|
303
|
+
.rmt-row.risky .rmt-t{color:var(--s-failed)}
|
|
304
|
+
.rmt-url{display:flex;align-items:center;gap:7px;margin:6px 0 2px}
|
|
305
|
+
.rmt-url code{flex:1;min-width:0;font-family:var(--mono);font-size:11.5px;color:var(--brand);
|
|
306
|
+
background:#0e1118;border:1px solid var(--line);border-radius:6px;padding:5px 8px;
|
|
307
|
+
overflow-x:auto;white-space:nowrap}
|
|
308
|
+
.rmt-warn{color:var(--s-failed);font-size:11.5px;margin:5px 0 2px;line-height:1.5}
|
|
309
|
+
.rmt-note{color:var(--faint);font-size:11.5px;margin:4px 0 2px;line-height:1.5}
|
|
310
|
+
/* toggle switch — quiet until on (brand) or risky (red) */
|
|
311
|
+
.tgl{position:relative;width:38px;height:22px;flex:none;background:var(--surface2);
|
|
312
|
+
border:1px solid var(--line);border-radius:999px;cursor:pointer;transition:background .15s,border-color .15s}
|
|
313
|
+
.tgl::after{content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;
|
|
314
|
+
background:var(--muted);transition:transform .15s,background .15s}
|
|
315
|
+
.tgl.on{background:var(--brand-dim);border-color:var(--brand)}
|
|
316
|
+
.tgl.on::after{transform:translateX(16px);background:var(--brand)}
|
|
317
|
+
.tgl.risky.on{background:rgba(226,91,103,.16);border-color:var(--s-failed)}
|
|
318
|
+
.tgl.risky.on::after{background:var(--s-failed)}
|
|
319
|
+
.tgl[aria-disabled="true"]{opacity:.4;cursor:not-allowed}
|
|
320
|
+
.rmt-cp{font-family:var(--mono);font-size:11px;color:var(--muted);background:var(--surface2);
|
|
321
|
+
border:1px solid var(--line);border-radius:6px;padding:5px 10px;cursor:pointer;flex:none}
|
|
322
|
+
.rmt-cp:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
323
|
+
/* collapsible recipes / url table */
|
|
324
|
+
.rmt-more{margin-top:12px;border-top:1px solid var(--line);padding-top:10px}
|
|
325
|
+
.rmt-more summary{cursor:pointer;font-size:12.5px;color:var(--muted);list-style:none;user-select:none}
|
|
326
|
+
.rmt-more summary::-webkit-details-marker{display:none}
|
|
327
|
+
.rmt-more summary::before{content:'▸ ';color:var(--faint)}
|
|
328
|
+
.rmt-more[open] summary::before{content:'▾ '}
|
|
329
|
+
.rmt-more pre{margin:8px 0 4px;padding:10px 12px;background:#0e1118;border:1px solid var(--line);
|
|
330
|
+
border-radius:8px;font-family:var(--mono);font-size:11px;color:var(--muted);overflow-x:auto;white-space:pre}
|
|
331
|
+
.rmt-cap{color:var(--faint);font-size:11px;margin:2px 0 10px;line-height:1.5}
|
|
332
|
+
.rmt-tbl{width:100%;border-collapse:collapse;margin:8px 0 2px;font-size:11.5px}
|
|
333
|
+
.rmt-tbl th,.rmt-tbl td{text-align:left;padding:5px 8px;border-bottom:1px solid var(--line);
|
|
334
|
+
color:var(--muted);vertical-align:top}
|
|
335
|
+
.rmt-tbl th{color:var(--faint);font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.1em}
|
|
336
|
+
.rmt-tbl code{font-family:var(--mono);font-size:10.5px;color:var(--brand);white-space:nowrap}
|
|
337
|
+
.rmt-tbl .star{color:var(--brand)}
|
|
338
|
+
/* header 🔗 affordance shares the ghost-button look */
|
|
339
|
+
|
|
244
340
|
/* ── toasts ─────────────────────────────── */
|
|
245
341
|
.toasts{position:fixed;top:66px;right:18px;z-index:60;display:flex;flex-direction:column;gap:8px;
|
|
246
342
|
max-width:380px}
|
|
@@ -405,6 +501,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
405
501
|
</div>
|
|
406
502
|
<div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
|
|
407
503
|
<button class="btn-ghost sm" id="bell" title="notify when a run settles">🔕</button>
|
|
504
|
+
<button class="btn-ghost sm" id="remoteBtn" title="reach this daemon from elsewhere (Tailscale · recipes)">🔗</button>
|
|
408
505
|
<div class="machines" id="machines"></div>
|
|
409
506
|
</header>
|
|
410
507
|
<div class="scrim" id="scrim"></div>
|
|
@@ -418,6 +515,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
418
515
|
<select id="taskRepo"></select>
|
|
419
516
|
<div class="row">
|
|
420
517
|
<button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
|
|
518
|
+
<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>
|
|
421
519
|
<button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
|
|
422
520
|
<button type="button" class="btn-ghost sm" id="repoBranch" style="flex:0 0 auto" title="change the base branch — merges, Sync base and PRs all target it">⎇</button>
|
|
423
521
|
<button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit">×</button>
|
|
@@ -541,6 +639,46 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
541
639
|
</div>
|
|
542
640
|
</div>
|
|
543
641
|
|
|
642
|
+
<div class="overlay" id="groupRoomOverlay">
|
|
643
|
+
<div class="room">
|
|
644
|
+
<div class="rh">
|
|
645
|
+
<span class="rh-glyph">⌒</span>
|
|
646
|
+
<span class="rh-t" id="roomTitle">Goal</span>
|
|
647
|
+
<span class="rh-n" id="roomCount"></span>
|
|
648
|
+
<button class="x" id="roomClose" aria-label="close">×</button>
|
|
649
|
+
</div>
|
|
650
|
+
<div class="chips" id="roomChips"></div>
|
|
651
|
+
<div class="body">
|
|
652
|
+
<div class="feed" id="roomFeed"></div>
|
|
653
|
+
<div class="conv" id="roomConv" hidden></div>
|
|
654
|
+
</div>
|
|
655
|
+
<div class="composer">
|
|
656
|
+
<div class="seg comp-seg" id="roomSeg" role="group" aria-label="workroom mode">
|
|
657
|
+
<button type="button" class="seg-opt on" data-rmode="work">✎ Work</button>
|
|
658
|
+
<button type="button" class="seg-opt" data-rmode="ask">? Ask</button>
|
|
659
|
+
</div>
|
|
660
|
+
<div class="comp-hint" id="roomHint"></div>
|
|
661
|
+
<textarea id="roomInput" placeholder="New attempt prompt, or a broadcast to the settled runs…"></textarea>
|
|
662
|
+
<div class="verbs" id="roomVerbs">
|
|
663
|
+
<button type="button" class="btn-ghost sm" id="roomNew">+ New attempt</button>
|
|
664
|
+
<button type="button" class="btn-ghost sm" id="roomBroadcast">→ Broadcast</button>
|
|
665
|
+
<span class="grow"></span>
|
|
666
|
+
<div class="conv-menu" id="roomConvMenu">
|
|
667
|
+
<button type="button" class="btn sm" id="roomConverge">Converge ▾</button>
|
|
668
|
+
<div class="conv-pop">
|
|
669
|
+
<button type="button" id="roomReview">Review group</button>
|
|
670
|
+
<button type="button" id="roomIntegrate">Select runs to integrate</button>
|
|
671
|
+
</div>
|
|
672
|
+
</div>
|
|
673
|
+
</div>
|
|
674
|
+
<div class="send-only" id="roomSend" hidden>
|
|
675
|
+
<span class="ro-hint">read-only — switch to Work to act</span>
|
|
676
|
+
<button type="button" class="btn sm" id="roomAsk">Ask</button>
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
|
|
544
682
|
<div class="overlay" id="cmpOverlay">
|
|
545
683
|
<div class="modal wide">
|
|
546
684
|
<div class="modal-h">
|
|
@@ -588,9 +726,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
588
726
|
</div>
|
|
589
727
|
<div class="brw-list" id="brwList"></div>
|
|
590
728
|
<div class="brw-f">
|
|
591
|
-
<span class="hint"
|
|
729
|
+
<span class="hint"><span style="color:var(--brand)">git</span> badge = repo (Register) · empty folder = Start here</span>
|
|
730
|
+
<button class="btn-ghost sm" id="brwNewFolder">+ New folder here</button>
|
|
592
731
|
<button class="btn sm" id="brwRegHere" style="display:none">Register this folder</button>
|
|
593
732
|
</div>
|
|
733
|
+
<form class="brw-f" id="brwNewForm" hidden>
|
|
734
|
+
<input id="brwNewName" placeholder="new-project-folder-name" style="flex:1" />
|
|
735
|
+
<button class="btn sm" type="submit" id="brwNewGo">Create & start</button>
|
|
736
|
+
</form>
|
|
594
737
|
</div>
|
|
595
738
|
</div>
|
|
596
739
|
|
|
@@ -618,6 +761,36 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
618
761
|
</div>
|
|
619
762
|
</div>
|
|
620
763
|
|
|
764
|
+
<div class="overlay" id="npOverlay">
|
|
765
|
+
<div class="cfm">
|
|
766
|
+
<div class="cfm-b">
|
|
767
|
+
<div class="m">Start a new project</div>
|
|
768
|
+
<div class="s">Creates the folder if needed, then git init + an empty initial commit as the base. Never touches non-empty folders. Then: write a task like 'scaffold a … app', run a fleet of 2–3, and compare the foundations.</div>
|
|
769
|
+
<p class="flabel" style="margin-top:12px">new project path</p>
|
|
770
|
+
<input id="npPath" placeholder="/abs/path/to/new-project" />
|
|
771
|
+
<p class="flabel" style="margin-top:10px">name · optional</p>
|
|
772
|
+
<input id="npName" placeholder="defaults to the folder name" />
|
|
773
|
+
</div>
|
|
774
|
+
<div class="cfm-f">
|
|
775
|
+
<button class="btn-ghost sm" id="npCancel">Cancel</button>
|
|
776
|
+
<button class="btn sm" id="npOk">Start new project</button>
|
|
777
|
+
</div>
|
|
778
|
+
</div>
|
|
779
|
+
</div>
|
|
780
|
+
|
|
781
|
+
<div class="overlay" id="remoteOverlay">
|
|
782
|
+
<div class="cfm" style="width:min(560px,94vw)">
|
|
783
|
+
<div class="cfm-b">
|
|
784
|
+
<div class="m">Remote access</div>
|
|
785
|
+
<div class="s">Reach this daemon from your other devices — coxpit detects your Tailscale and drives it, or hands a copy-paste recipe. It never hosts a relay.</div>
|
|
786
|
+
<div id="remoteBody" class="rmt" style="margin-top:14px">loading…</div>
|
|
787
|
+
</div>
|
|
788
|
+
<div class="cfm-f">
|
|
789
|
+
<button class="btn-ghost sm" id="remoteClose">Close</button>
|
|
790
|
+
</div>
|
|
791
|
+
</div>
|
|
792
|
+
</div>
|
|
793
|
+
|
|
621
794
|
<div class="overlay" id="brOverlay">
|
|
622
795
|
<div class="cfm">
|
|
623
796
|
<div class="cfm-b">
|
|
@@ -666,6 +839,8 @@ const runs = new Map(); // runId -> run object
|
|
|
666
839
|
const tasks = new Map(); // taskId -> task
|
|
667
840
|
const groups = new Map(); // groupId -> {id, kind, title}
|
|
668
841
|
let repos = [], machines = [], captures = [];
|
|
842
|
+
let daemonPort = 8210; // real config.port — filled from /api/fleet daemon block (recipes interpolate it)
|
|
843
|
+
let remoteAuthOpen = false; // true = no password → Funnel guard on (from /api/fleet daemon.authOpen)
|
|
669
844
|
|
|
670
845
|
const $ = (id) => document.getElementById(id);
|
|
671
846
|
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
|
@@ -729,7 +904,8 @@ async function brwGo(p){
|
|
|
729
904
|
(d.error ? '<div class="brw-row"><span class="nm" style="color:var(--s-failed)">'+esc(d.error)+'</span></div>' : '')
|
|
730
905
|
+ (d.dirs.map(x =>
|
|
731
906
|
'<div class="brw-row" data-n="'+esc(x.name)+'"><span class="ico">▸</span><span class="nm">'+esc(x.name)+'</span>'
|
|
732
|
-
+ (x.isRepo ? '<span class="gitchip">git</span><button type="button" class="btn sm" data-reg="'+esc(x.name)+'">Register</button>'
|
|
907
|
+
+ (x.isRepo ? '<span class="gitchip">git</span><button type="button" class="btn sm" data-reg="'+esc(x.name)+'">Register</button>'
|
|
908
|
+
: x.isEmpty ? '<button type="button" class="btn-ghost sm" data-start="'+esc(x.name)+'">Start here</button>' : '')
|
|
733
909
|
+ '</div>').join('')
|
|
734
910
|
|| '<div class="brw-row"><span class="nm" style="color:var(--faint)">no folders here</span></div>');
|
|
735
911
|
}
|
|
@@ -740,26 +916,56 @@ async function brwRegister(fullPath){
|
|
|
740
916
|
toast('repo registered — pick it under Launch agents', 'ok');
|
|
741
917
|
$('brwOverlay').classList.remove('open');
|
|
742
918
|
await hydrate();
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const j = await res.json().catch(()=>({}));
|
|
922
|
+
if (res.status === 400 && j.code === 'NO_COMMITS'){
|
|
923
|
+
const yes = await confirmUI('This folder has no commits yet — start a new project here?',
|
|
924
|
+
{ sub: 'coxpit will create an empty initial commit as the base, then register the repo. The folder itself is untouched.', okLabel: 'Start new project' });
|
|
925
|
+
if (yes && await createNewProject(fullPath)) $('brwOverlay').classList.remove('open');
|
|
926
|
+
return;
|
|
746
927
|
}
|
|
928
|
+
toast('register: '+(j.detail||j.error||res.status), 'error');
|
|
747
929
|
}
|
|
748
930
|
$('repoBrowse').addEventListener('click', ()=>{
|
|
749
931
|
const m = machines.find(x=>x.slug===$('repoMachine').value);
|
|
750
932
|
if (m && m.address){ toast('remote machine — type the path manually for now', 'error'); return; }
|
|
933
|
+
$('brwNewForm').hidden = true; $('brwNewName').value = '';
|
|
751
934
|
$('brwOverlay').classList.add('open');
|
|
752
935
|
brwGo(brwCur || '');
|
|
753
936
|
});
|
|
754
937
|
$('brwList').addEventListener('click',(e)=>{
|
|
755
938
|
const reg = e.target.closest('button[data-reg]');
|
|
756
939
|
if (reg){ brwRegister(brwCur.replace(/\\/$/,'')+'/'+reg.dataset.reg); return; }
|
|
940
|
+
const start = e.target.closest('button[data-start]');
|
|
941
|
+
if (start){ brwStartHere(brwCur.replace(/\\/$/,'')+'/'+start.dataset.start); return; }
|
|
757
942
|
const row = e.target.closest('.brw-row[data-n]');
|
|
758
943
|
if (row) brwGo(brwCur.replace(/\\/$/,'')+'/'+row.dataset.n);
|
|
759
944
|
});
|
|
760
945
|
$('brwUp').addEventListener('click',(e)=>brwGo(e.currentTarget.dataset.p));
|
|
761
946
|
$('brwHome').addEventListener('click',(e)=>brwGo(e.currentTarget.dataset.p));
|
|
762
947
|
$('brwRegHere').addEventListener('click',()=>brwRegister(brwCur));
|
|
948
|
+
/* 빈 폴더 행의 Start here — 클릭한 폴더에 greenfield 씨앗 심고 등록 */
|
|
949
|
+
async function brwStartHere(fullPath){
|
|
950
|
+
if (await createNewProject(fullPath)) $('brwOverlay').classList.remove('open');
|
|
951
|
+
}
|
|
952
|
+
/* + New folder here — 현재 탐색 위치에 새 폴더명만 받아 생성+시작 (절대경로 타이핑 불필요) */
|
|
953
|
+
$('brwNewFolder').addEventListener('click', ()=>{
|
|
954
|
+
const f = $('brwNewForm'); f.hidden = !f.hidden; if(!f.hidden) $('brwNewName').focus();
|
|
955
|
+
});
|
|
956
|
+
$('brwNewForm').addEventListener('submit', async (e)=>{
|
|
957
|
+
e.preventDefault();
|
|
958
|
+
const name = $('brwNewName').value.trim();
|
|
959
|
+
if (!name){ toast('enter a folder name', 'error'); return; }
|
|
960
|
+
if (name.includes('/')){ toast('folder name cannot contain /', 'error'); return; }
|
|
961
|
+
const go = $('brwNewGo'); go.disabled = true; go.textContent = 'Creating…';
|
|
962
|
+
try{
|
|
963
|
+
if (await createNewProject(brwCur.replace(/\\/$/,'')+'/'+name)){
|
|
964
|
+
$('brwNewName').value=''; $('brwNewForm').hidden=true;
|
|
965
|
+
$('brwOverlay').classList.remove('open');
|
|
966
|
+
}
|
|
967
|
+
} finally { go.disabled=false; go.textContent='Create & start'; }
|
|
968
|
+
});
|
|
763
969
|
$('brwClose').addEventListener('click',()=>$('brwOverlay').classList.remove('open'));
|
|
764
970
|
$('brwOverlay').addEventListener('click',(e)=>{ if(e.target===$('brwOverlay')) $('brwOverlay').classList.remove('open'); });
|
|
765
971
|
|
|
@@ -793,7 +999,9 @@ function humanize(e){
|
|
|
793
999
|
try{
|
|
794
1000
|
const o = JSON.parse(payload);
|
|
795
1001
|
if (o.type === 'system'){
|
|
796
|
-
if (o.subtype === 'init' || !o.subtype) return { k:'session',
|
|
1002
|
+
if (o.subtype === 'init' || !o.subtype) return { k:'session',
|
|
1003
|
+
t:'started'+(o.model?' · '+String(o.model).replace(/\\u001b\\[[0-9;]*m/g,'')
|
|
1004
|
+
.replace(/\\x1b\\[[0-9;]*m/g,'') : '') };
|
|
797
1005
|
if (o.subtype === 'permission_denied') return { k:'denied', t:'⛔ '+(o.tool_name||o.tool||'tool use')+' blocked — attach the Terminal to approve, or widen COXPIT_AGENT_PERM' };
|
|
798
1006
|
return null; // thinking_tokens 등 스트림 잡음
|
|
799
1007
|
}
|
|
@@ -897,6 +1105,7 @@ function bandHTML(g, grpRuns){
|
|
|
897
1105
|
+ '<span class="gband-t">'+title+'</span>'
|
|
898
1106
|
+ '<span class="gband-n">'+taskIds.length+' task'+(taskIds.length>1?'s':'')+' · '+settled+' settled</span>'
|
|
899
1107
|
+ '<span class="gband-sp"></span>'
|
|
1108
|
+
+ '<button class="btn-ghost sm gband-open" data-groom="'+g.id+'">⌒ Open workroom</button>'
|
|
900
1109
|
+ '<button class="btn-ghost sm" data-gsel="'+g.id+'">Select runs</button>'
|
|
901
1110
|
+ '<button class="btn-ghost sm" data-gclose="'+g.id+'">Close group</button>'
|
|
902
1111
|
+ '<button class="gband-fold" data-gfold="'+g.id+'" title="fold">'+(folded?'▸':'▾')+'</button></div>'
|
|
@@ -1033,7 +1242,11 @@ function paintOnboarding(){
|
|
|
1033
1242
|
+ '<li><b>Register a repo</b> — absolute path, in the left sidebar</li>'
|
|
1034
1243
|
+ '<li><b>Write a task</b> — title + a prompt that names the target files</li>'
|
|
1035
1244
|
+ '<li><b>Run fleet</b> — try <b>Dry run</b> first (free rehearsal), then <b>Real agent</b></li>'
|
|
1036
|
-
+ '</ol></div
|
|
1245
|
+
+ '</ol></div>'
|
|
1246
|
+
+ '<div class="setup-sec"><p class="setup-label">Remote access</p>'
|
|
1247
|
+
+ '<div id="rmtOnboard" class="rmt">checking Tailscale…</div></div>'
|
|
1248
|
+
+ '</div>';
|
|
1249
|
+
loadRemote();
|
|
1037
1250
|
}
|
|
1038
1251
|
function cardHTML(r){
|
|
1039
1252
|
const task = tasks.get(r.taskId);
|
|
@@ -1080,6 +1293,8 @@ async function hydrate(){
|
|
|
1080
1293
|
(r.runs||[]).forEach(rn => runs.set(rn.id, { ...rn, events: rn.events||[] }));
|
|
1081
1294
|
if (r.daemon) {
|
|
1082
1295
|
const d = r.daemon;
|
|
1296
|
+
if (d.port) daemonPort = d.port;
|
|
1297
|
+
remoteAuthOpen = !!d.authOpen;
|
|
1083
1298
|
const db = String(d.dbPath||'').replace(/^\\/(?:Users|home)\\/[^/]+/, '~');
|
|
1084
1299
|
const el = $('daemonBadge');
|
|
1085
1300
|
el.innerHTML = 'daemon <b>v'+esc(String(d.version||'?'))+'</b> · :'+esc(String(d.port||'?'));
|
|
@@ -1191,6 +1406,7 @@ function connectWS(){
|
|
|
1191
1406
|
let ev; try{ ev = JSON.parse(m.data); }catch{ return; }
|
|
1192
1407
|
// 아카이브 뷰는 정적 스냅샷 — 라이브 갱신으로 뒤엎지 않는다.
|
|
1193
1408
|
if (boardView==='archive' && (ev.type==='run'||ev.type==='event'||ev.type==='task')) return;
|
|
1409
|
+
roomOnWS(ev); // 워크룸이 열려있으면 그룹 소속 run/event 를 방에 반영
|
|
1194
1410
|
if (ev.type==='run'){
|
|
1195
1411
|
const rid = ev.runId ?? ev.id;
|
|
1196
1412
|
const known = runs.has(rid);
|
|
@@ -1342,6 +1558,8 @@ $('selGo').addEventListener('click', async ()=>{
|
|
|
1342
1558
|
|
|
1343
1559
|
/* ── 그룹 밴드 액션 (fold / Select runs / Close group) ── */
|
|
1344
1560
|
$('grid').addEventListener('click', async (e)=>{
|
|
1561
|
+
const groom = e.target.closest('[data-groom]');
|
|
1562
|
+
if (groom){ openRoom(Number(groom.dataset.groom)); return; }
|
|
1345
1563
|
const fold = e.target.closest('[data-gfold]');
|
|
1346
1564
|
if (fold){ const g=Number(fold.dataset.gfold); if(gfold.has(g)) gfold.delete(g); else gfold.add(g); saveGfold(); render(); return; }
|
|
1347
1565
|
const gsel = e.target.closest('[data-gsel]');
|
|
@@ -1402,7 +1620,7 @@ $('grid').addEventListener('click',(e)=>{
|
|
|
1402
1620
|
});
|
|
1403
1621
|
$('mClose').addEventListener('click', closeModal);
|
|
1404
1622
|
$('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
|
|
1405
|
-
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); $('brOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
1623
|
+
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); $('npOverlay').classList.remove('open'); $('brOverlay').classList.remove('open'); $('remoteOverlay').classList.remove('open'); closeRoom(); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
1406
1624
|
$('mRefreshDiff').addEventListener('click', loadDiff);
|
|
1407
1625
|
$('mExport').addEventListener('click', ()=>{
|
|
1408
1626
|
if (openRunId==null) return;
|
|
@@ -1604,6 +1822,254 @@ $('mCompare').addEventListener('click', ()=>{
|
|
|
1604
1822
|
closeModal(); openCompare(r.taskId);
|
|
1605
1823
|
});
|
|
1606
1824
|
|
|
1825
|
+
/* ── goal workroom (v4.6 L1) — 한 goal 을 여는 단일 방 ── */
|
|
1826
|
+
let roomGroupId = null; // 열려있는 그룹 id (null = 닫힘)
|
|
1827
|
+
const roomRunSet = new Set(); // 이 그룹에 속한 runId — WS 필터용
|
|
1828
|
+
let roomRuns = []; // 마지막 aggregate 의 runs (chips/scope hint)
|
|
1829
|
+
const roomExtra = []; // goal-level 마커(spawn/broadcast) — 피드에 섞음
|
|
1830
|
+
const roomTurns = []; // Ask 대화 턴 [{role:'you'|'coord', text}] — 읽기 전용
|
|
1831
|
+
let roomMode = 'work'; // 'work' | 'ask' — 방 composer 모드(스틸모드와 무관, 방 전용)
|
|
1832
|
+
/* run → 상태색 (chip / who) */
|
|
1833
|
+
function roomStatusColor(s){ return statusColor(s); }
|
|
1834
|
+
function roomChipHTML(r){
|
|
1835
|
+
const s = r.status||'pending';
|
|
1836
|
+
const meta = ['done','failed','stopped','merged'].includes(s)
|
|
1837
|
+
? (r.filesChanged? '+'+r.filesChanged+'f' : 'no changes')
|
|
1838
|
+
: (r.agent||'agent')+(s==='running'?' · editing…':'');
|
|
1839
|
+
return '<span class="chip'+(s==='running'?' running':'')+(r.isNew?' new':'')+'" data-roomrun="'+r.runId+'"'
|
|
1840
|
+
+ ' style="color:'+roomStatusColor(s)+';border-color:'+roomStatusColor(s)+'">'
|
|
1841
|
+
+ '<i></i><span class="cid">r'+r.runId+'</span> <span class="cmeta">'+esc(meta)+'</span></span>';
|
|
1842
|
+
}
|
|
1843
|
+
function roomRenderChips(){
|
|
1844
|
+
$('roomChips').innerHTML = roomRuns.length
|
|
1845
|
+
? roomRuns.map(roomChipHTML).join('')
|
|
1846
|
+
: '<span style="color:var(--faint);font-family:var(--mono);font-size:11.5px">no runs yet</span>';
|
|
1847
|
+
}
|
|
1848
|
+
function roomFeedLine(who, whoStatus, text, mark){
|
|
1849
|
+
return '<div class="fl'+(mark?' mark':'')+'"><span class="who" style="color:'
|
|
1850
|
+
+ (mark?'var(--brand)':roomStatusColor(whoStatus))+'">'+esc(who)+'</span>'
|
|
1851
|
+
+ '<span class="ft">'+esc(text)+'</span></div>';
|
|
1852
|
+
}
|
|
1853
|
+
function roomRenderFeed(events){
|
|
1854
|
+
// run 이벤트 + goal-level 마커를 시간(id/순서)대로. 간단히: 이벤트 먼저, 마커는 append.
|
|
1855
|
+
const statusOf = (rid)=>{ const r = roomRuns.find(x=>x.runId===rid); return r? r.status : 'pending'; };
|
|
1856
|
+
const rows = [];
|
|
1857
|
+
for (const e of (events||[])){
|
|
1858
|
+
const h = humanize({ kind:e.kind, payload:e.payload });
|
|
1859
|
+
if (!h) continue;
|
|
1860
|
+
const list = Array.isArray(h) ? h : [h];
|
|
1861
|
+
for (const x of list) rows.push(roomFeedLine('r'+e.runId, statusOf(e.runId), x.k+' · '+x.t, false));
|
|
1862
|
+
}
|
|
1863
|
+
for (const m of roomExtra){
|
|
1864
|
+
rows.push(roomFeedLine(m.who, 'pending', m.text, true));
|
|
1865
|
+
}
|
|
1866
|
+
$('roomFeed').innerHTML = rows.length ? rows.join('')
|
|
1867
|
+
: '<div class="empty-note">no activity yet — spawn an attempt or wait for runs to report</div>';
|
|
1868
|
+
$('roomFeed').scrollTop = $('roomFeed').scrollHeight;
|
|
1869
|
+
}
|
|
1870
|
+
/* Ask 대화(읽기 전용) — you / coordinator 턴. 코디네이터 답 아래 read-only 노트. */
|
|
1871
|
+
function roomRenderConv(){
|
|
1872
|
+
if (!roomTurns.length){
|
|
1873
|
+
$('roomConv').innerHTML = '<div class="empty-note">read-only coordinator — ask what these attempts are doing. it never acts.</div>';
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
$('roomConv').innerHTML = roomTurns.map(t=>{
|
|
1877
|
+
const role = t.role==='you' ? 'you' : 'coordinator';
|
|
1878
|
+
const note = t.role==='coord'
|
|
1879
|
+
? '<span class="ro-note">read-only — switch to Work to act</span>' : '';
|
|
1880
|
+
return '<div class="turn '+t.role+'"><span class="role">'+esc(role)+'</span>'
|
|
1881
|
+
+ '<span class="msg">'+esc(t.text)+'</span>'+note+'</div>';
|
|
1882
|
+
}).join('');
|
|
1883
|
+
$('roomConv').scrollTop = $('roomConv').scrollHeight;
|
|
1884
|
+
}
|
|
1885
|
+
function roomRenderHeaderAndHint(){
|
|
1886
|
+
const g = groups.get(roomGroupId) || {};
|
|
1887
|
+
const done = roomRuns.filter(r=>['done','failed','stopped','merged'].includes(r.status)).length;
|
|
1888
|
+
const running = roomRuns.filter(r=>r.live).length;
|
|
1889
|
+
$('roomTitle').textContent = 'Goal: '+(g.title||'');
|
|
1890
|
+
$('roomCount').textContent = '· '+roomRuns.length+' run'+(roomRuns.length===1?'':'s')
|
|
1891
|
+
+' · '+done+' done · '+running+' running';
|
|
1892
|
+
roomRenderHint();
|
|
1893
|
+
}
|
|
1894
|
+
/* Work=정직한 steer 스코프, Ask=읽기 전용 안내. 모드에 따라 힌트/뷰/composer 전환. */
|
|
1895
|
+
function roomRenderHint(){
|
|
1896
|
+
if (roomMode==='ask'){
|
|
1897
|
+
$('roomHint').textContent = 'read-only coordinator · reads run diffs, never acts';
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
const running = roomRuns.filter(r=>r.live).length;
|
|
1901
|
+
const steerable = roomRuns.filter(r=>r.steerable).length;
|
|
1902
|
+
$('roomHint').textContent = steerable+' steerable now · '+running+' still running';
|
|
1903
|
+
}
|
|
1904
|
+
/* feed↔conv, verbs↔send-only 를 roomMode 로 스왑. */
|
|
1905
|
+
function roomApplyMode(){
|
|
1906
|
+
const ask = roomMode==='ask';
|
|
1907
|
+
$('roomFeed').hidden = ask;
|
|
1908
|
+
$('roomConv').hidden = !ask;
|
|
1909
|
+
$('roomVerbs').hidden = ask;
|
|
1910
|
+
$('roomSend').hidden = !ask;
|
|
1911
|
+
$('roomInput').placeholder = ask
|
|
1912
|
+
? 'Ask the coordinator about these attempts (read-only)…'
|
|
1913
|
+
: 'New attempt prompt, or a broadcast to the settled runs…';
|
|
1914
|
+
document.querySelectorAll('#roomSeg .seg-opt').forEach(x=>x.classList.toggle('on', x.dataset.rmode===roomMode));
|
|
1915
|
+
roomRenderHint();
|
|
1916
|
+
if (ask) roomRenderConv();
|
|
1917
|
+
}
|
|
1918
|
+
async function openRoom(groupId){
|
|
1919
|
+
roomGroupId = groupId;
|
|
1920
|
+
roomExtra.length = 0; roomRunSet.clear(); roomRuns = []; roomTurns.length = 0;
|
|
1921
|
+
roomMode = 'work';
|
|
1922
|
+
$('roomInput').value = '';
|
|
1923
|
+
$('roomChips').innerHTML = '<span style="color:var(--faint);font-family:var(--mono);font-size:11.5px">loading…</span>';
|
|
1924
|
+
$('roomFeed').innerHTML = '';
|
|
1925
|
+
$('roomConv').innerHTML = '';
|
|
1926
|
+
$('roomTitle').textContent = 'Goal';
|
|
1927
|
+
$('roomCount').textContent = '';
|
|
1928
|
+
$('roomHint').textContent = '';
|
|
1929
|
+
roomApplyMode();
|
|
1930
|
+
$('groupRoomOverlay').classList.add('open');
|
|
1931
|
+
await roomLoad();
|
|
1932
|
+
}
|
|
1933
|
+
async function roomLoad(){
|
|
1934
|
+
if (roomGroupId==null) return;
|
|
1935
|
+
let j;
|
|
1936
|
+
try{ j = await fetch('/api/groups/'+roomGroupId).then(x=>x.json()); }
|
|
1937
|
+
catch{ toast('workroom load failed', 'error'); return; }
|
|
1938
|
+
if (!j || !j.group){ toast('group not found', 'error'); return; }
|
|
1939
|
+
if (!groups.has(j.group.id)) groups.set(j.group.id, j.group);
|
|
1940
|
+
roomRuns = j.runs||[];
|
|
1941
|
+
roomRunSet.clear();
|
|
1942
|
+
roomRuns.forEach(r=>roomRunSet.add(r.runId));
|
|
1943
|
+
roomRenderChips();
|
|
1944
|
+
roomRenderHeaderAndHint();
|
|
1945
|
+
roomRenderFeed(j.events||[]);
|
|
1946
|
+
}
|
|
1947
|
+
function closeRoom(){ roomGroupId = null; $('groupRoomOverlay').classList.remove('open'); }
|
|
1948
|
+
/* chip 클릭 → 그 run 을 기존 run 모달로 */
|
|
1949
|
+
$('roomChips').addEventListener('click',(e)=>{
|
|
1950
|
+
const c = e.target.closest('[data-roomrun]'); if(!c) return;
|
|
1951
|
+
const rid = Number(c.dataset.roomrun);
|
|
1952
|
+
closeRoom();
|
|
1953
|
+
if (runs.has(rid)) openModal(rid);
|
|
1954
|
+
else { // 아카이브 등 맵에 없는 run — 태스크로 하이드레이트 후 연다
|
|
1955
|
+
fetch('/api/runs/'+rid).then(x=>x.json()).then(d=>{ if(d.run){ runs.set(rid, {...d.run, events:d.events||[]}); openModal(rid); } }).catch(()=>{});
|
|
1956
|
+
}
|
|
1957
|
+
});
|
|
1958
|
+
$('roomClose').addEventListener('click', closeRoom);
|
|
1959
|
+
$('groupRoomOverlay').addEventListener('click',(e)=>{ if(e.target===$('groupRoomOverlay')) closeRoom(); });
|
|
1960
|
+
/* + New attempt — 같은 그룹에 새 시도 발사 (현재 dry/real 토글 반영) */
|
|
1961
|
+
$('roomNew').addEventListener('click', async ()=>{
|
|
1962
|
+
if (roomGroupId==null) return;
|
|
1963
|
+
const prompt = $('roomInput').value.trim();
|
|
1964
|
+
if (!prompt){ toast('write a prompt for the new attempt', 'error'); return; }
|
|
1965
|
+
const real = $('taskReal').checked;
|
|
1966
|
+
const btn = $('roomNew'); btn.disabled = true;
|
|
1967
|
+
try{
|
|
1968
|
+
const res = await fetch('/api/groups/'+roomGroupId+'/spawn',{method:'POST',
|
|
1969
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({prompt, real})});
|
|
1970
|
+
const j = await res.json().catch(()=>({}));
|
|
1971
|
+
if (res.ok){
|
|
1972
|
+
$('roomInput').value='';
|
|
1973
|
+
(j.tasks||[]).forEach(t=>{ roomExtra.push({ who:'goal +', text:'new attempt r'+t.runId+' — '+prompt.slice(0,60) }); roomRunSet.add(t.runId); });
|
|
1974
|
+
toast((j.tasks||[]).length+' attempt(s) launched'+(real?'':' (dry)'), 'ok');
|
|
1975
|
+
roomRenderFeed([]); await roomLoad(); hydrate();
|
|
1976
|
+
} else toast('spawn: '+(j.error||res.status), 'error');
|
|
1977
|
+
} finally { btn.disabled = false; }
|
|
1978
|
+
});
|
|
1979
|
+
/* → Broadcast — 그룹의 정착 steerable run 전부에 후속 지시(라이브/드라이는 정직하게 skip) */
|
|
1980
|
+
$('roomBroadcast').addEventListener('click', async ()=>{
|
|
1981
|
+
if (roomGroupId==null) return;
|
|
1982
|
+
const message = $('roomInput').value.trim();
|
|
1983
|
+
if (!message){ toast('write a broadcast message', 'error'); return; }
|
|
1984
|
+
const btn = $('roomBroadcast'); btn.disabled = true;
|
|
1985
|
+
try{
|
|
1986
|
+
const res = await fetch('/api/groups/'+roomGroupId+'/steer',{method:'POST',
|
|
1987
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({message, mode:roomMode})});
|
|
1988
|
+
const j = await res.json().catch(()=>({}));
|
|
1989
|
+
if (res.ok){
|
|
1990
|
+
$('roomInput').value='';
|
|
1991
|
+
roomExtra.push({ who:'goal →', text:message.slice(0,80)+' ('+j.detail+')' });
|
|
1992
|
+
toast('broadcast: '+j.detail, (j.skipped&&j.skipped.length)?undefined:'ok');
|
|
1993
|
+
roomRenderFeed([]); await roomLoad();
|
|
1994
|
+
} else toast('broadcast: '+(j.error||res.status), 'error');
|
|
1995
|
+
} finally { btn.disabled = false; }
|
|
1996
|
+
});
|
|
1997
|
+
/* Converge ▾ — 기존 review/integrate 를 노출(재구현 아님) */
|
|
1998
|
+
$('roomConverge').addEventListener('click',(e)=>{ e.stopPropagation(); $('roomConvMenu').classList.toggle('open'); });
|
|
1999
|
+
document.addEventListener('click',()=>$('roomConvMenu').classList.remove('open'));
|
|
2000
|
+
$('roomReview').addEventListener('click', ()=>{
|
|
2001
|
+
$('roomConvMenu').classList.remove('open');
|
|
2002
|
+
// 그룹 태스크가 여럿이면 각 태스크가 자체 비교/리뷰를 가짐 — 첫 태스크의 Compare 로 진입.
|
|
2003
|
+
const first = roomRuns[0];
|
|
2004
|
+
if (!first){ toast('no runs to review', 'error'); return; }
|
|
2005
|
+
closeRoom(); openCompare(first.taskId);
|
|
2006
|
+
});
|
|
2007
|
+
$('roomIntegrate').addEventListener('click', ()=>{
|
|
2008
|
+
$('roomConvMenu').classList.remove('open');
|
|
2009
|
+
const g = roomGroupId; closeRoom();
|
|
2010
|
+
if (!selectMode) setSelectMode(true);
|
|
2011
|
+
// 그룹의 정착·변경 run 전부 사전 선택(기존 밴드 Select runs 와 동일 규칙)
|
|
2012
|
+
const tids = new Set([...tasks.values()].filter(t=>t.groupId===g).map(t=>t.id));
|
|
2013
|
+
for (const r of [...runs.values()].sort((a,b)=>a.id-b.id)){
|
|
2014
|
+
if (!tids.has(r.taskId)) continue;
|
|
2015
|
+
const ok = ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0)>0 && r.status!=='merged';
|
|
2016
|
+
if (ok && !selected.has(r.id)){ selected.add(r.id); selOrder.push(r.id); }
|
|
2017
|
+
}
|
|
2018
|
+
$('selCnt').textContent = selected.size + ' selected';
|
|
2019
|
+
render();
|
|
2020
|
+
if (!selected.size) toast('no settled runs with changes in this group yet', 'error');
|
|
2021
|
+
});
|
|
2022
|
+
/* Work | Ask 세그 — Work=행동(spawn/broadcast/converge), Ask=읽기 전용 코디네이터. 방 전용 모드. */
|
|
2023
|
+
document.querySelectorAll('#roomSeg .seg-opt').forEach(b=>{
|
|
2024
|
+
b.addEventListener('click', ()=>{
|
|
2025
|
+
roomMode = b.dataset.rmode;
|
|
2026
|
+
roomApplyMode();
|
|
2027
|
+
});
|
|
2028
|
+
});
|
|
2029
|
+
/* ? Ask — 그룹 스코프 읽기 전용 코디네이터. 절대 발사·steer·쓰기 없음 (POST /ask). */
|
|
2030
|
+
$('roomAsk').addEventListener('click', async ()=>{
|
|
2031
|
+
if (roomGroupId==null) return;
|
|
2032
|
+
const message = $('roomInput').value.trim();
|
|
2033
|
+
if (!message){ toast('write a question for the coordinator', 'error'); return; }
|
|
2034
|
+
const real = $('taskReal').checked;
|
|
2035
|
+
const btn = $('roomAsk'); btn.disabled = true;
|
|
2036
|
+
roomTurns.push({ role:'you', text:message });
|
|
2037
|
+
roomRenderConv();
|
|
2038
|
+
$('roomInput').value='';
|
|
2039
|
+
try{
|
|
2040
|
+
const res = await fetch('/api/groups/'+roomGroupId+'/ask',{method:'POST',
|
|
2041
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({message, real})});
|
|
2042
|
+
const j = await res.json().catch(()=>({}));
|
|
2043
|
+
if (res.ok && j.answer){
|
|
2044
|
+
roomTurns.push({ role:'coord', text:j.answer });
|
|
2045
|
+
} else {
|
|
2046
|
+
roomTurns.push({ role:'coord', text:'(ask failed — '+(j.error||res.status)+')' });
|
|
2047
|
+
toast('ask: '+(j.error||res.status), 'error');
|
|
2048
|
+
}
|
|
2049
|
+
roomRenderConv();
|
|
2050
|
+
} catch { toast('ask request failed', 'error'); }
|
|
2051
|
+
finally { btn.disabled = false; }
|
|
2052
|
+
});
|
|
2053
|
+
/* WS 라이브 — 그룹 소속 run/event 만 방에 반영 (connectWS 가 호출) */
|
|
2054
|
+
function roomOnWS(ev){
|
|
2055
|
+
if (roomGroupId==null) return;
|
|
2056
|
+
if (ev.type==='run'){
|
|
2057
|
+
const rid = ev.runId ?? ev.id;
|
|
2058
|
+
if (!roomRunSet.has(rid)) return; // 이 방 소속만
|
|
2059
|
+
roomLoad(); // 상태·steerable 재계산은 aggregate 로 (정확)
|
|
2060
|
+
} else if (ev.type==='event'){
|
|
2061
|
+
if (!roomRunSet.has(ev.runId)) return;
|
|
2062
|
+
const h = humanize({ kind:ev.kind, payload:ev.payload });
|
|
2063
|
+
if (h){
|
|
2064
|
+
const r = roomRuns.find(x=>x.runId===ev.runId);
|
|
2065
|
+
const list = Array.isArray(h) ? h : [h];
|
|
2066
|
+
const frag = list.map(x=>roomFeedLine('r'+ev.runId, r?r.status:'pending', x.k+' · '+x.t, false)).join('');
|
|
2067
|
+
$('roomFeed').insertAdjacentHTML('beforeend', frag);
|
|
2068
|
+
$('roomFeed').scrollTop = $('roomFeed').scrollHeight;
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
|
|
1607
2073
|
/* ── terminal — 초기크기 접속·자동 재연결(백오프)·unicode11·CJK 폰트 ── */
|
|
1608
2074
|
let termWS = null, termObj = null, fitAddon = null, termResizeObs = null;
|
|
1609
2075
|
let termRunId = null, termClosing = false, termRetry = 0, termRetryTimer = null;
|
|
@@ -1780,11 +2246,19 @@ async function submitBench(){
|
|
|
1780
2246
|
|
|
1781
2247
|
$('repoForm').addEventListener('submit', async (e)=>{
|
|
1782
2248
|
e.preventDefault();
|
|
1783
|
-
const
|
|
1784
|
-
|
|
2249
|
+
const path = $('repoPath').value.trim();
|
|
2250
|
+
const body = { machineSlug: $('repoMachine').value, path };
|
|
2251
|
+
if (!path) return;
|
|
1785
2252
|
const res = await fetch('/api/repos',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
|
|
1786
|
-
if (res.ok){ $('repoPath').value=''; toast('repo registered', 'ok'); await hydrate(); }
|
|
1787
|
-
|
|
2253
|
+
if (res.ok){ $('repoPath').value=''; toast('repo registered', 'ok'); await hydrate(); return; }
|
|
2254
|
+
const j = await res.json().catch(()=>({}));
|
|
2255
|
+
if (res.status === 400 && j.code === 'NO_COMMITS'){
|
|
2256
|
+
const yes = await confirmUI('This folder has no commits yet — start a new project here?',
|
|
2257
|
+
{ sub: 'coxpit will create an empty initial commit as the base, then register the repo. The folder itself is untouched.', okLabel: 'Start new project' });
|
|
2258
|
+
if (yes && await createNewProject(path)) $('repoPath').value='';
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
toast('repo: '+(j.detail||j.error||res.status), 'error');
|
|
1788
2262
|
});
|
|
1789
2263
|
$('taskForm').addEventListener('submit', async (e)=>{
|
|
1790
2264
|
e.preventDefault();
|
|
@@ -1874,6 +2348,42 @@ async function ghFetch(){
|
|
|
1874
2348
|
$('ghOk').addEventListener('click', ghFetch);
|
|
1875
2349
|
$('ghUrl').addEventListener('keydown',(e)=>{ if(e.key==='Enter') ghFetch(); });
|
|
1876
2350
|
|
|
2351
|
+
/* ── greenfield — start a new project (empty folder in, scaffolded repo out) ── */
|
|
2352
|
+
// POST /api/repos/new; 성공 시 hydrate 후 새 repo 를 자동 선택하고 toast.
|
|
2353
|
+
async function createNewProject(path, name){
|
|
2354
|
+
const body = { machineSlug: $('repoMachine').value, path };
|
|
2355
|
+
if (name) body.name = name;
|
|
2356
|
+
const res = await fetch('/api/repos/new',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
|
|
2357
|
+
const j = await res.json().catch(()=>({}));
|
|
2358
|
+
if (!res.ok){ toast(j.error||('new project: '+res.status), 'error'); return false; }
|
|
2359
|
+
await hydrate();
|
|
2360
|
+
if (j.repo){
|
|
2361
|
+
$('taskRepo').value = String(j.repo.id);
|
|
2362
|
+
syncSelect('taskRepo');
|
|
2363
|
+
}
|
|
2364
|
+
toast('project ready — write a scaffold task and Run fleet', 'ok');
|
|
2365
|
+
return true;
|
|
2366
|
+
}
|
|
2367
|
+
$('repoNew').addEventListener('click', ()=>{
|
|
2368
|
+
const m = machines.find(x=>x.slug===$('repoMachine').value);
|
|
2369
|
+
$('npPath').value = ''; $('npName').value = '';
|
|
2370
|
+
$('npOverlay').classList.add('open'); $('npPath').focus();
|
|
2371
|
+
});
|
|
2372
|
+
$('npCancel').addEventListener('click', ()=>$('npOverlay').classList.remove('open'));
|
|
2373
|
+
$('npOverlay').addEventListener('click',(e)=>{ if(e.target===$('npOverlay')) $('npOverlay').classList.remove('open'); });
|
|
2374
|
+
async function npStart(){
|
|
2375
|
+
const path = $('npPath').value.trim();
|
|
2376
|
+
if (!path){ toast('enter an absolute project path', 'error'); return; }
|
|
2377
|
+
$('npOk').disabled = true; $('npOk').textContent = 'Starting…';
|
|
2378
|
+
try{
|
|
2379
|
+
const ok = await createNewProject(path, $('npName').value.trim());
|
|
2380
|
+
if (ok) $('npOverlay').classList.remove('open');
|
|
2381
|
+
} finally { $('npOk').disabled = false; $('npOk').textContent = 'Start new project'; }
|
|
2382
|
+
}
|
|
2383
|
+
$('npOk').addEventListener('click', npStart);
|
|
2384
|
+
$('npPath').addEventListener('keydown',(e)=>{ if(e.key==='Enter') npStart(); });
|
|
2385
|
+
$('npName').addEventListener('keydown',(e)=>{ if(e.key==='Enter') npStart(); });
|
|
2386
|
+
|
|
1877
2387
|
/* ── 읽기 전용 공유 링크 ── */
|
|
1878
2388
|
$('mShare').addEventListener('click', async ()=>{
|
|
1879
2389
|
if (openRunId==null) return;
|
|
@@ -1886,6 +2396,156 @@ $('mShare').addEventListener('click', async ()=>{
|
|
|
1886
2396
|
toast((j.existing?'share link (existing)':'share link created')+(copied?' — copied':'')+': '+url, 'ok');
|
|
1887
2397
|
});
|
|
1888
2398
|
|
|
2399
|
+
/* ── remote access (v4.5) — detect Tailscale, drive Serve/Funnel, or hand a recipe.
|
|
2400
|
+
coxpit never hosts a relay: it detects and drives the user's own tool. ── */
|
|
2401
|
+
let remoteData = null, remoteBusy = false;
|
|
2402
|
+
|
|
2403
|
+
// clipboard write with a synchronous execCommand fallback (clipboard API alone is
|
|
2404
|
+
// unreliable outside secure/focused contexts — mirrors the terminal copy helper).
|
|
2405
|
+
function copyText(text){
|
|
2406
|
+
let ok = false;
|
|
2407
|
+
try{ if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text); ok = true; } }catch{}
|
|
2408
|
+
try{
|
|
2409
|
+
const ta = document.createElement('textarea');
|
|
2410
|
+
ta.value = text; ta.setAttribute('readonly',''); ta.style.position='fixed'; ta.style.top='-1000px';
|
|
2411
|
+
document.body.appendChild(ta); ta.select();
|
|
2412
|
+
if (document.execCommand('copy')) ok = true;
|
|
2413
|
+
document.body.removeChild(ta);
|
|
2414
|
+
}catch{}
|
|
2415
|
+
return ok;
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// recipe text with the daemon's REAL port interpolated (guidance, not magic).
|
|
2419
|
+
function cfRecipe(){
|
|
2420
|
+
return 'cloudflared tunnel --url http://localhost:'+daemonPort+'\\n'
|
|
2421
|
+
+ '# or a named tunnel + Cloudflare Access policy (recommended — exposes shells)';
|
|
2422
|
+
}
|
|
2423
|
+
function caddyRecipe(){
|
|
2424
|
+
return 'coxpit.example.com {\\n reverse_proxy 127.0.0.1:'+daemonPort+'\\n}';
|
|
2425
|
+
}
|
|
2426
|
+
function urlTableHTML(){
|
|
2427
|
+
const rows = [
|
|
2428
|
+
['local', 'http://127.0.0.1:'+daemonPort, 'same machine'],
|
|
2429
|
+
['LAN', 'http://192.168.x.y:'+daemonPort, 'home network'],
|
|
2430
|
+
['Tailscale IP', 'http://100.x.y.z:'+daemonPort, 'your tailnet'],
|
|
2431
|
+
['MagicDNS', 'http://<machine>.<tailnet>.ts.net:'+daemonPort, 'your tailnet'],
|
|
2432
|
+
['Serve ⭐', 'https://<machine>.<tailnet>.ts.net', 'your tailnet · HTTPS'],
|
|
2433
|
+
['Funnel', 'https://<machine>.<tailnet>.ts.net', 'public internet'],
|
|
2434
|
+
['Cloudflare', 'https://coxpit.yourdomain.com', 'public (+ CF Access)'],
|
|
2435
|
+
['reverse proxy', 'https://coxpit.yourdomain.com', 'public'],
|
|
2436
|
+
];
|
|
2437
|
+
let body = '';
|
|
2438
|
+
for (const row of rows){
|
|
2439
|
+
const star = row[0].indexOf('⭐') >= 0 ? ' class="star"' : '';
|
|
2440
|
+
body += '<tr'+star+'><td>'+esc(row[0])+'</td><td><code>'+row[1]+'</code></td><td>'+esc(row[2])+'</td></tr>';
|
|
2441
|
+
}
|
|
2442
|
+
return '<table class="rmt-tbl"><tr><th>method</th><th>url shape</th><th>who reaches it</th></tr>'+body+'</table>'
|
|
2443
|
+
+ '<div class="rmt-cap">coxpit hands you a <code style="color:var(--brand)">*.ts.net</code> name in one click; a <b>custom</b> domain stays a Cloudflare/proxy recipe.</div>';
|
|
2444
|
+
}
|
|
2445
|
+
function recipesHTML(){
|
|
2446
|
+
return '<details class="rmt-more"><summary>Recipes — Cloudflare Tunnel & reverse proxy</summary>'
|
|
2447
|
+
+ '<pre>'+esc(cfRecipe())+'</pre>'
|
|
2448
|
+
+ '<div class="rmt-cap">public = shells exposed; keep coxpit auth on.</div>'
|
|
2449
|
+
+ '<pre>'+esc(caddyRecipe())+'</pre>'
|
|
2450
|
+
+ '<div class="rmt-cap">public = shells exposed; keep coxpit auth on.</div>'
|
|
2451
|
+
+ '</details>';
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// authOpen: no password set → Funnel would expose shells to the internet.
|
|
2455
|
+
function remoteCardHTML(rd){
|
|
2456
|
+
if (!rd) return '<div class="rmt-line">checking Tailscale…</div>' + recipesHTML();
|
|
2457
|
+
const authOpen = !!rd.authOpen;
|
|
2458
|
+
let h = '';
|
|
2459
|
+
if (rd.tailscale === 'missing'){
|
|
2460
|
+
h += '<div class="rmt-line">Install Tailscale to reach this daemon by name from your other devices — or use a reverse-proxy recipe below. '
|
|
2461
|
+
+ '<a href="https://tailscale.com/download" target="_blank" rel="noopener" style="color:var(--brand)">tailscale.com/download</a></div>';
|
|
2462
|
+
} else if (rd.tailscale === 'stopped'){
|
|
2463
|
+
h += '<div class="rmt-line">Tailscale is installed but not running. Start it, then <a href="#" id="rmtRefresh" style="color:var(--brand)">refresh</a>.</div>';
|
|
2464
|
+
} else {
|
|
2465
|
+
// running
|
|
2466
|
+
h += '<div class="rmt-line">This machine on your tailnet: <span class="rmt-name">'+esc(rd.dnsName||'')+'</span></div>';
|
|
2467
|
+
const serveUrl = 'https://'+(rd.dnsName||'');
|
|
2468
|
+
// Serve row (safe default)
|
|
2469
|
+
h += '<div class="rmt-row"><div class="rmt-l"><div class="rmt-t">Serve</div>'
|
|
2470
|
+
+ '<div class="rmt-d">your tailnet only · HTTPS · no port</div></div>'
|
|
2471
|
+
+ '<div class="tgl'+(rd.serve?' on':'')+'" id="rmtServe" role="switch" aria-checked="'+(rd.serve?'true':'false')+'"></div></div>';
|
|
2472
|
+
if (rd.serve){
|
|
2473
|
+
h += '<div class="rmt-url"><code>'+esc(serveUrl)+'</code>'
|
|
2474
|
+
+ '<button class="rmt-cp" data-copy="'+escA(serveUrl)+'">Copy</button></div>';
|
|
2475
|
+
}
|
|
2476
|
+
// Funnel row (risky)
|
|
2477
|
+
h += '<div class="rmt-row risky"><div class="rmt-l"><div class="rmt-t">Funnel · Public internet</div>'
|
|
2478
|
+
+ '<div class="rmt-d">'+(authOpen?'set COXPIT_AUTH_PASS first — Funnel exposes shells':'anyone with the URL can reach this — auth is your only gate')+'</div></div>'
|
|
2479
|
+
+ '<div class="tgl risky'+(rd.funnel?' on':'')+'" id="rmtFunnel" role="switch" aria-checked="'+(rd.funnel?'true':'false')+'"'+(authOpen?' aria-disabled="true"':'')+'></div></div>';
|
|
2480
|
+
if (rd.funnel){
|
|
2481
|
+
h += '<div class="rmt-url"><code>'+esc(serveUrl)+'</code>'
|
|
2482
|
+
+ '<button class="rmt-cp" data-copy="'+escA(serveUrl)+'">Copy</button></div>';
|
|
2483
|
+
h += '<div class="rmt-warn">anyone with the URL can reach this — auth is your only gate.</div>';
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
h += recipesHTML();
|
|
2487
|
+
h += '<details class="rmt-more"><summary>how URLs differ</summary>'+urlTableHTML()+'</details>';
|
|
2488
|
+
return h;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
async function loadRemote(){
|
|
2492
|
+
// /api/remote stays a pure RemoteState; the auth-open flag comes from /api/fleet
|
|
2493
|
+
// (remoteAuthOpen) so the Funnel toggle can disable itself before any POST.
|
|
2494
|
+
try{
|
|
2495
|
+
const rd = await fetch('/api/remote').then(x=>x.json());
|
|
2496
|
+
remoteData = rd; remoteData.authOpen = remoteAuthOpen;
|
|
2497
|
+
paintRemote();
|
|
2498
|
+
}catch{ remoteData = { tailscale:'missing', serve:false, funnel:false, authOpen: remoteAuthOpen }; paintRemote(); }
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
function paintRemote(){
|
|
2502
|
+
const html = remoteCardHTML(remoteData);
|
|
2503
|
+
const ov = $('remoteBody'); if (ov) ov.innerHTML = html;
|
|
2504
|
+
const ob = $('rmtOnboard'); if (ob) ob.innerHTML = html;
|
|
2505
|
+
wireRemote($('remoteOverlay'));
|
|
2506
|
+
wireRemote(document.getElementById('empty'));
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
function wireRemote(scope){
|
|
2510
|
+
if (!scope) return;
|
|
2511
|
+
scope.querySelectorAll('[data-copy]').forEach(b=>{
|
|
2512
|
+
if (b.dataset.wired) return; b.dataset.wired='1';
|
|
2513
|
+
b.addEventListener('click', ()=>{
|
|
2514
|
+
const ok = copyText(b.getAttribute('data-copy')||'');
|
|
2515
|
+
toast(ok?'URL copied':'copy failed — select it manually', ok?'ok':'error');
|
|
2516
|
+
});
|
|
2517
|
+
});
|
|
2518
|
+
const rf = scope.querySelector('#rmtRefresh');
|
|
2519
|
+
if (rf && !rf.dataset.wired){ rf.dataset.wired='1'; rf.addEventListener('click',(e)=>{ e.preventDefault(); loadRemote(); }); }
|
|
2520
|
+
const sv = scope.querySelector('#rmtServe');
|
|
2521
|
+
if (sv && !sv.dataset.wired){ sv.dataset.wired='1'; sv.addEventListener('click', ()=>toggleRemote('serve', !(remoteData&&remoteData.serve))); }
|
|
2522
|
+
const fn = scope.querySelector('#rmtFunnel');
|
|
2523
|
+
if (fn && !fn.dataset.wired){ fn.dataset.wired='1'; fn.addEventListener('click', ()=>{
|
|
2524
|
+
if (fn.getAttribute('aria-disabled')==='true'){ toast('set COXPIT_AUTH_PASS first — Funnel exposes shells', 'error'); return; }
|
|
2525
|
+
toggleRemote('funnel', !(remoteData&&remoteData.funnel));
|
|
2526
|
+
}); }
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
async function toggleRemote(which, on){
|
|
2530
|
+
if (remoteBusy) return; remoteBusy = true;
|
|
2531
|
+
try{
|
|
2532
|
+
const res = await fetch('/api/remote/'+which,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({on})});
|
|
2533
|
+
const j = await res.json().catch(()=>({}));
|
|
2534
|
+
if (!res.ok){
|
|
2535
|
+
if (j.code==='NO_AUTH'){ remoteAuthOpen = true; if (remoteData) remoteData.authOpen = true; paintRemote(); toast('set COXPIT_AUTH_PASS first — Funnel exposes shells', 'error'); }
|
|
2536
|
+
else toast(which+': '+(j.error||res.status), 'error');
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
remoteData = j; remoteData.authOpen = remoteAuthOpen;
|
|
2540
|
+
paintRemote();
|
|
2541
|
+
toast(which+(on?' on':' off'), 'ok');
|
|
2542
|
+
} finally { remoteBusy = false; }
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
$('remoteBtn').addEventListener('click', ()=>{ $('remoteOverlay').classList.add('open'); loadRemote(); });
|
|
2546
|
+
$('remoteClose').addEventListener('click', ()=>$('remoteOverlay').classList.remove('open'));
|
|
2547
|
+
$('remoteOverlay').addEventListener('click',(e)=>{ if(e.target===$('remoteOverlay')) $('remoteOverlay').classList.remove('open'); });
|
|
2548
|
+
|
|
1889
2549
|
/* ── mobile drawer ── */
|
|
1890
2550
|
const asideEl = document.querySelector('aside');
|
|
1891
2551
|
function setDrawer(on){ asideEl.classList.toggle('open', on); $('scrim').classList.toggle('on', on); }
|