coxpit 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "3.0.1",
3
+ "version": "3.1.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
@@ -13,12 +13,13 @@ export const BOARD_HTML = /* html */ `<!doctype html>
13
13
  --ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
14
14
  --brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
15
15
  --s-pending:#8792a2; --s-preparing:#d6a249; --s-running:#55a7e0;
16
- --s-done:#58b368; --s-failed:#e25b67; --s-error:#e25b67; --s-stopped:#b58be0; --s-merged:#4ec9b0;
16
+ --s-done:#58b368; --s-failed:#e25b67; --s-error:#e25b67; --s-stopped:#b58be0; --s-merged:#4ec9b0; --s-open:#7f9cf5;
17
17
  --mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
18
18
  --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
19
19
  --r-card:10px; --r-ctl:8px; --shadow:0 8px 28px rgba(0,0,0,.35);
20
20
  }
21
21
  *{box-sizing:border-box}
22
+ [hidden]{display:none !important}
22
23
  html,body{height:100%}
23
24
  body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
24
25
  -webkit-font-smoothing:antialiased}
@@ -298,60 +299,65 @@ export const BOARD_HTML = /* html */ `<!doctype html>
298
299
  <div class="layout">
299
300
  <aside>
300
301
  <div class="sect">
301
- <p class="sect-label">Repositories</p>
302
- <div id="repos" style="display:flex;flex-direction:column;gap:6px"></div>
303
- <form id="repoForm">
304
- <p class="flabel">machine</p>
305
- <select id="repoMachine"></select>
306
- <p class="flabel">add a repository</p>
307
- <button class="btn sm" type="button" id="repoBrowse">Browse this machine…</button>
302
+ <p class="sect-label">Context</p>
303
+ <p class="flabel">machine</p>
304
+ <select id="repoMachine"></select>
305
+ <p class="flabel">repository</p>
306
+ <select id="taskRepo"></select>
307
+ <div class="row">
308
+ <button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
309
+ <button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
310
+ <button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit">×</button>
311
+ </div>
312
+ <form id="repoForm" hidden>
308
313
  <div class="row">
309
- <input id="repoPath" placeholder="or type an absolute path" />
314
+ <input id="repoPath" placeholder="/abs/path/to/repo" />
310
315
  <button class="btn-ghost sm" type="submit" style="flex:0 0 auto">Register</button>
311
316
  </div>
312
317
  </form>
313
318
  </div>
319
+
314
320
  <div class="sect">
315
- <p class="sect-label">Launch agents</p>
321
+ <p class="sect-label">Start</p>
322
+ <div class="seg" id="launchTabs">
323
+ <button type="button" class="seg-opt on" data-tab="task">Task</button>
324
+ <button type="button" class="seg-opt" data-tab="goal">Goal</button>
325
+ <button type="button" class="seg-opt" data-tab="bench">Workbench</button>
326
+ </div>
316
327
  <form id="taskForm">
317
- <p class="flabel">repository</p>
318
- <select id="taskRepo"></select>
319
- <p class="flabel">task</p>
320
- <input id="taskTitle" placeholder="Task title" />
321
- <textarea id="taskPrompt" placeholder="Prompt name the target files, constraints, and how to verify"></textarea>
322
- <p class="flabel">design capture · optional</p>
323
- <select id="taskCapture"><option value="">no design capture</option></select>
324
- <p class="flabel">mode</p>
328
+ <div id="panelTask" style="display:flex;flex-direction:column;gap:8px">
329
+ <input id="taskTitle" placeholder="Task title" />
330
+ <textarea id="taskPrompt" placeholder="Prompt — target files, constraints, how to verify"></textarea>
331
+ <p class="flabel">design capture · optional</p>
332
+ <select id="taskCapture"><option value="">no design capture</option></select>
333
+ </div>
334
+ <div id="panelGoal" hidden style="flex-direction:column;gap:8px">
335
+ <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>
336
+ </div>
337
+ <div id="panelBench" hidden style="flex-direction:column;gap:8px">
338
+ <input id="benchTitle" placeholder="Workbench name · optional" />
339
+ <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>
340
+ </div>
325
341
  <div class="seg" id="modeSeg" role="group" aria-label="agent mode">
326
342
  <button type="button" class="seg-opt" data-real="0">Dry run</button>
327
343
  <button type="button" class="seg-opt" data-real="1">Real agent<span class="seg-hint">spends credits</span></button>
328
344
  </div>
329
345
  <input type="checkbox" id="taskReal" hidden />
330
346
  <div class="row">
331
- <input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="number of agents — 1 for a job, N to explore variants" />
347
+ <input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="agents — 1 for a job, N to explore variants" />
332
348
  <button class="btn" type="submit" id="runFleetBtn">Run fleet</button>
333
349
  </div>
334
350
  </form>
335
351
  </div>
336
- <div class="sect">
337
- <p class="sect-label">Plan a goal · swarm</p>
338
- <form id="planForm">
339
- <p class="flabel">repository</p>
340
- <select id="planRepo"></select>
341
- <p class="flabel">goal</p>
342
- <textarea id="planGoal" placeholder="One goal — a planner agent reads the repo, splits it into independent tasks, and launches them all. Converge later with Select runs → Integrate."></textarea>
343
- <button class="btn" type="submit" id="planGo">Plan &amp; fan out</button>
344
- <span style="font-size:11px;color:var(--faint);font-family:var(--mono)">follows the Dry/Real mode above · planner reads only</span>
345
- </form>
346
- </div>
347
- <div class="sect">
348
- <p class="sect-label">Design captures</p>
349
- <div id="captures" style="display:flex;flex-direction:column;gap:6px"></div>
350
- <a id="bmk" class="btn-ghost sm" style="text-decoration:none;text-align:center;display:block;padding:6px"
352
+
353
+ <details class="sect" id="libraryBox">
354
+ <summary class="sect-label" style="cursor:pointer;list-style:none">Library · design captures ▾</summary>
355
+ <div id="captures" style="display:flex;flex-direction:column;gap:6px;margin-top:10px"></div>
356
+ <a id="bmk" class="btn-ghost sm" style="text-decoration:none;text-align:center;display:block;padding:6px;margin-top:6px"
351
357
  title="drag me to your bookmarks bar, then click it on your running app">⌖ coxpit inspect</a>
352
358
  <span style="font-size:11px;color:var(--faint)">Drag to bookmarks. Click it on your app, then click an element.
353
359
  With auth on, append ?k=&lt;pass&gt; to the script URL.</span>
354
- </div>
360
+ </details>
355
361
  </aside>
356
362
  <main>
357
363
  <div class="toolbar"><button class="btn-ghost sm" id="selToggle">Select runs</button></div>
@@ -767,23 +773,19 @@ async function hydrate(){
767
773
  function paintSidebar(){
768
774
  $('machines').innerHTML = machines.map(m =>
769
775
  '<span class="mchip"><span class="mdot '+(m.online?'on':'')+'"></span><b>'+esc(m.slug)+'</b></span>').join('');
770
- $('repos').innerHTML = repos.map(r =>
771
- '<div class="repo"><span class="nm">'+esc(r.name)+'</span><span class="br">'+esc(r.defaultBranch)+'</span>'
772
- + '<button class="x" data-delrepo="'+r.id+'" title="remove repo" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
773
- + '<div class="path">'+esc(r.path)+'</div></div>').join('')
774
- || '<div class="repo" style="color:var(--faint)">none registered</div>';
775
776
  $('repoMachine').innerHTML = machines.map(m=>'<option value="'+esc(m.slug)+'">'+esc(m.slug)+'</option>').join('');
777
+ const prevRepo = $('taskRepo').value;
776
778
  $('taskRepo').innerHTML = repos.length
777
- ? repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+'</option>').join('')
778
- : '<option value="">register a repo first ↑</option>';
779
+ ? repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+' · '+esc(r.defaultBranch)+'</option>').join('')
780
+ : '<option value="">register a repo Browse… ↓</option>';
781
+ if (prevRepo && repos.some(r=>String(r.id)===prevRepo)) $('taskRepo').value = prevRepo;
779
782
  $('runFleetBtn').disabled = !repos.length;
780
783
  const capSel = $('taskCapture');
781
784
  const cur = capSel.value;
782
785
  capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
783
786
  '<option value="'+c.id+'">#'+c.id+' '+esc((c.selector||'').slice(0,40))+'</option>').join('');
784
787
  capSel.value = cur;
785
- $('planRepo').innerHTML = $('taskRepo').innerHTML;
786
- ['repoMachine','taskRepo','taskCapture','planRepo'].forEach(id => { dressSelect(id); syncSelect(id); });
788
+ ['repoMachine','taskRepo','taskCapture'].forEach(id => { dressSelect(id); syncSelect(id); });
787
789
  $('captures').innerHTML = captures.map(c=>
788
790
  '<div class="repo"><span class="nm">'+esc((c.selector||'?').slice(0,46))+'</span>'
789
791
  + '<button class="x" data-delcap="'+c.id+'" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
@@ -797,16 +799,21 @@ $('captures').addEventListener('click', async (e)=>{
797
799
  await fetch('/api/design/'+b.dataset.delcap,{method:'DELETE'});
798
800
  hydrate();
799
801
  });
800
- $('repos').addEventListener('click', async (e)=>{
801
- const b = e.target.closest('button[data-delrepo]'); if(!b) return;
802
- const yes = await confirmUI('Remove this repository from coxpit?',
802
+ $('repoRemove').addEventListener('click', async ()=>{
803
+ const rid = $('taskRepo').value;
804
+ if (!rid){ toast('no repository selected', 'error'); return; }
805
+ const yes = await confirmUI('Remove the selected repository from coxpit?',
803
806
  { sub: 'The repo itself is untouched — only the registration is removed. Refused while it has open tasks.', danger: true, okLabel: 'Remove' });
804
807
  if (!yes) return;
805
- const res = await fetch('/api/repos/'+b.dataset.delrepo,{method:'DELETE'});
808
+ const res = await fetch('/api/repos/'+rid,{method:'DELETE'});
806
809
  const j = await res.json().catch(()=>({}));
807
810
  if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
808
811
  else toast('remove: '+(j.detail||res.status), 'error');
809
812
  });
813
+ $('repoManual').addEventListener('click', ()=>{
814
+ const f = $('repoForm'); f.hidden = !f.hidden;
815
+ if (!f.hidden) $('repoPath').focus();
816
+ });
810
817
 
811
818
  /* ── 완료 알림(브라우저) — 벨 토글, run 정착 시 통지 ── */
812
819
  let notifyOn = false;
@@ -1172,28 +1179,53 @@ $('mTerm').addEventListener('click', ()=>{
1172
1179
  });
1173
1180
 
1174
1181
  /* ── forms ── */
1175
- $('planForm').addEventListener('submit', async (e)=>{
1176
- e.preventDefault();
1177
- const repoId = Number($('planRepo').value);
1182
+ /* ── 런처 탭 — 하나의 시작점: Task | Goal | Workbench ── */
1183
+ let lTab = 'task';
1184
+ const L_LABEL = { task: 'Run fleet', goal: 'Plan & fan out', bench: 'Open workbench' };
1185
+ document.querySelectorAll('#launchTabs .seg-opt').forEach(b=>{
1186
+ b.addEventListener('click', ()=>{
1187
+ lTab = b.dataset.tab;
1188
+ document.querySelectorAll('#launchTabs .seg-opt').forEach(x=>x.classList.toggle('on', x===b));
1189
+ $('panelTask').style.display = lTab==='task' ? 'flex' : 'none';
1190
+ $('panelGoal').hidden = lTab!=='goal';
1191
+ $('panelGoal').style.display = lTab==='goal' ? 'flex' : 'none';
1192
+ $('panelBench').hidden = lTab!=='bench';
1193
+ $('panelBench').style.display = lTab==='bench' ? 'flex' : 'none';
1194
+ $('modeSeg').style.display = lTab==='bench' ? 'none' : 'flex'; // workbench 는 에이전트 없음
1195
+ $('taskCount').style.display = lTab==='task' ? '' : 'none';
1196
+ $('runFleetBtn').textContent = L_LABEL[lTab];
1197
+ });
1198
+ });
1199
+
1200
+ async function submitGoal(){
1201
+ const repoId = Number($('taskRepo').value);
1178
1202
  const goal = $('planGoal').value.trim();
1179
1203
  if (!repoId){ toast('register a repo first', 'error'); return; }
1180
1204
  if (!goal){ toast('write a goal first', 'error'); return; }
1181
1205
  const real = $('taskReal').checked;
1182
- const btn = $('planGo');
1206
+ const btn = $('runFleetBtn');
1183
1207
  btn.disabled = true; btn.textContent = real ? 'Planning… (1–3 min)' : 'Planning…';
1184
1208
  try{
1185
1209
  const res = await fetch('/api/plan',{method:'POST',headers:{'content-type':'application/json'},
1186
1210
  body:JSON.stringify({repoId, goal, real})});
1187
1211
  const j = await res.json().catch(()=>({}));
1188
- if (res.ok){
1189
- toast(j.tasks.length+' task(s) planned & launched', 'ok');
1190
- $('planGoal').value='';
1191
- hydrate();
1192
- } else toast('plan: '+(j.detail||j.error||res.status), 'error');
1193
- } finally {
1194
- btn.disabled = false; btn.textContent = 'Plan & fan out';
1195
- }
1196
- });
1212
+ if (res.ok){ toast(j.tasks.length+' task(s) planned & launched', 'ok'); $('planGoal').value=''; hydrate(); }
1213
+ else toast('plan: '+(j.detail||j.error||res.status), 'error');
1214
+ } finally { btn.disabled = false; btn.textContent = L_LABEL[lTab]; }
1215
+ }
1216
+
1217
+ async function submitBench(){
1218
+ const repoId = Number($('taskRepo').value);
1219
+ if (!repoId){ toast('register a repo first', 'error'); return; }
1220
+ const res = await fetch('/api/workbench',{method:'POST',headers:{'content-type':'application/json'},
1221
+ body:JSON.stringify({repoId, title: $('benchTitle').value.trim()})});
1222
+ const j = await res.json().catch(()=>({}));
1223
+ if (!res.ok){ toast('workbench: '+(j.detail||j.error||res.status), 'error'); return; }
1224
+ toast('workbench open — terminal attached', 'ok');
1225
+ $('benchTitle').value='';
1226
+ await hydrate();
1227
+ openTerm(j.runId); // 바로 터미널로
1228
+ }
1197
1229
 
1198
1230
  $('repoForm').addEventListener('submit', async (e)=>{
1199
1231
  e.preventDefault();
@@ -1205,9 +1237,11 @@ $('repoForm').addEventListener('submit', async (e)=>{
1205
1237
  });
1206
1238
  $('taskForm').addEventListener('submit', async (e)=>{
1207
1239
  e.preventDefault();
1240
+ if (lTab === 'goal') return submitGoal();
1241
+ if (lTab === 'bench') return submitBench();
1208
1242
  const repoId = Number($('taskRepo').value);
1209
1243
  const title = $('taskTitle').value.trim();
1210
- if (!repoId || !title) return;
1244
+ if (!repoId || !title){ toast(!repoId?'register a repo first':'task title required', 'error'); return; }
1211
1245
  const capId = Number($('taskCapture').value) || undefined;
1212
1246
  const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
1213
1247
  body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
@@ -413,6 +413,49 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
413
413
  return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
414
414
  }
415
415
 
416
+ /**
417
+ * Workbench — 인터랙티브 작업방: 격리 worktree + tmux 만 만들고 에이전트는
418
+ * 띄우지 않는다. 사람이 터미널로 들어가(원하면 claude TUI 로) 오래 작업하고,
419
+ * coxpit 은 diff·merge·PR·export 레일만 제공한다. status='open'.
420
+ */
421
+ export async function openWorkbench(repoId: number, title: string): Promise<{
422
+ ok: boolean; detail: string; taskId?: number; runId?: number;
423
+ }> {
424
+ const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
425
+ const repo = rp[0];
426
+ if (!repo) return { ok: false, detail: 'repo not found' };
427
+ const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
428
+ const m = mr[0];
429
+ if (!m) return { ok: false, detail: 'machine not found' };
430
+ const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
431
+
432
+ const tIns = await db.insert(tasks).values({ repoId, title: title || 'Workbench', prompt: '(interactive workbench)' }).returning();
433
+ const task = tIns[0]!;
434
+ const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'workbench', status: 'pending' }).returning();
435
+ const run = rIns[0]!;
436
+ const runId = run.id;
437
+ broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent: 'workbench', branch: '', filesChanged: 0 });
438
+
439
+ const branch = `coxpit/r${runId}`;
440
+ const wtParent = ppath.join(ppath.dirname(repo.path), '.coxpit-worktrees');
441
+ const wtPath = ppath.join(wtParent, `r${runId}`);
442
+ const session = `coxpit-r${runId}`;
443
+
444
+ const prep = await runShellOn(
445
+ machine,
446
+ `mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
447
+ ` && tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)}`,
448
+ 20000,
449
+ );
450
+ if (!prep.ok) {
451
+ await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'workbench prep failed' });
452
+ return { ok: false, detail: (prep.stderr || prep.stdout).trim().slice(0, 300) };
453
+ }
454
+ await setRun(runId, { status: 'open', branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
455
+ await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, workbench: true }));
456
+ return { ok: true, detail: 'workbench open', taskId: task.id, runId };
457
+ }
458
+
416
459
  /**
417
460
  * Plan fan-out — 스웜의 입구. 목표 하나를 받아 플래너 에이전트가 repo 를 읽고
418
461
  * 독립 실행 가능한 하위 태스크들로 분해 → 각 태스크를 count 1 로 자동 발사한다.
@@ -635,7 +678,7 @@ export async function prRun(runId: number): Promise<{ ok: boolean; detail: strin
635
678
  const run = rr[0];
636
679
  if (!ctx || !run || !run.worktreePath || !run.branch) return { ok: false, detail: 'no worktree/branch' };
637
680
  if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
638
- if (!['done', 'failed', 'stopped'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
681
+ if (!['done', 'failed', 'stopped', 'open'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
639
682
  const wt = shq(run.worktreePath);
640
683
 
641
684
  // 0) 사전 조건: origin 리모트 + gh CLI
package/src/server.ts CHANGED
@@ -12,7 +12,7 @@ import { db } from './db';
12
12
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures } from './db/schema';
13
13
  import { BOOKMARKLET_JS } from './design';
14
14
  import { runShellOn, shq } from './exec';
15
- import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun } from './orchestrator';
15
+ import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench } from './orchestrator';
16
16
  import { openTerm } from './term';
17
17
  import { addSink, removeSink, broadcast } from './hub';
18
18
  import { BOARD_HTML } from './board';
@@ -32,7 +32,7 @@ export async function buildServer(): Promise<FastifyInstance> {
32
32
  app.addHook('onRequest', authGate);
33
33
 
34
34
  // 무인증 헬스(외부 감시용)
35
- app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.0.1' }));
35
+ app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.1.0' }));
36
36
 
37
37
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
38
38
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
@@ -422,6 +422,16 @@ export async function buildServer(): Promise<FastifyInstance> {
422
422
  return res;
423
423
  });
424
424
 
425
+ // Workbench — 인터랙티브 작업방(worktree+tmux, 에이전트 없음).
426
+ app.post('/api/workbench', async (req, reply) => {
427
+ const b = (req.body ?? {}) as { repoId?: number; title?: string };
428
+ const repoId = Number(b.repoId);
429
+ if (!repoId) return reply.code(400).send({ error: 'repoId required' });
430
+ const res = await openWorkbench(repoId, (b.title ?? '').trim());
431
+ if (!res.ok) return reply.code(422).send(res);
432
+ return reply.code(201).send(res);
433
+ });
434
+
425
435
  // Plan fan-out — 목표 하나 → 플래너가 태스크 분해 → 전부 자동 발사.
426
436
  // (real 플래너는 repo 를 읽고 계획하느라 1~3분 걸릴 수 있음 — 클라이언트는 대기)
427
437
  app.post('/api/plan', async (req, reply) => {
@@ -491,7 +501,7 @@ export async function buildServer(): Promise<FastifyInstance> {
491
501
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
492
502
  app.get('/ws', { websocket: true }, (socket) => {
493
503
  addSink(socket);
494
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.0.1' }));
504
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.1.0' }));
495
505
  socket.on('close', () => removeSink(socket));
496
506
  });
497
507