coxpit 2.1.2 → 2.2.1
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 +45 -1
- package/src/db/index.ts +3 -1
- package/src/db/schema.ts +1 -0
- package/src/orchestrator.ts +85 -44
- package/src/server.ts +16 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
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
|
@@ -71,6 +71,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
71
71
|
.row .narrow{flex:0 0 64px}
|
|
72
72
|
.check{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);cursor:pointer;user-select:none}
|
|
73
73
|
.check input{width:auto;accent-color:var(--brand)}
|
|
74
|
+
.seg{display:flex;gap:3px;padding:3px;border:1px solid var(--line);border-radius:var(--r-ctl);background:#0e1118}
|
|
75
|
+
.seg-opt{flex:1;display:flex;flex-direction:column;align-items:center;gap:1px;padding:6px 8px;border:none;
|
|
76
|
+
background:transparent;color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;
|
|
77
|
+
border-radius:calc(var(--r-ctl) - 3px);transition:background .15s,color .15s}
|
|
78
|
+
.seg-opt:hover{color:var(--ink)}
|
|
79
|
+
.seg-opt.on{background:var(--surface2);color:var(--ink)}
|
|
80
|
+
.seg-opt[data-real="1"].on{background:var(--brand);color:var(--brand-ink)}
|
|
81
|
+
.seg-hint{font-family:var(--mono);font-size:9.5px;font-weight:500;text-transform:uppercase;letter-spacing:.06em;opacity:.7}
|
|
74
82
|
|
|
75
83
|
/* ── buttons ────────────────────────────── */
|
|
76
84
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:none;cursor:pointer;
|
|
@@ -195,11 +203,15 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
195
203
|
<input id="taskTitle" placeholder="Task title" />
|
|
196
204
|
<textarea id="taskPrompt" placeholder="Prompt for the agents…"></textarea>
|
|
197
205
|
<select id="taskCapture"><option value="">no design capture</option></select>
|
|
206
|
+
<div class="seg" id="modeSeg" role="group" aria-label="agent mode">
|
|
207
|
+
<button type="button" class="seg-opt" data-real="0">Dry run</button>
|
|
208
|
+
<button type="button" class="seg-opt" data-real="1">Real agent<span class="seg-hint">spends credits</span></button>
|
|
209
|
+
</div>
|
|
210
|
+
<input type="checkbox" id="taskReal" hidden />
|
|
198
211
|
<div class="row">
|
|
199
212
|
<input id="taskCount" class="narrow" type="number" min="1" max="8" value="2" title="number of agents" />
|
|
200
213
|
<button class="btn" type="submit">Run fleet</button>
|
|
201
214
|
</div>
|
|
202
|
-
<label class="check"><input type="checkbox" id="taskReal" /> real agent · spends credits</label>
|
|
203
215
|
</form>
|
|
204
216
|
</div>
|
|
205
217
|
<div class="sect">
|
|
@@ -239,6 +251,10 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
239
251
|
<div class="pane-c"><pre class="diff" id="mDiff">loading…</pre></div>
|
|
240
252
|
</div>
|
|
241
253
|
</div>
|
|
254
|
+
<div class="modal-f" id="steerRow" style="border-top:1px solid var(--line)">
|
|
255
|
+
<input id="steerInput" placeholder="Send follow-up instructions — continues in the same session & worktree…" style="flex:1" />
|
|
256
|
+
<button class="btn sm" id="steerSend">Steer</button>
|
|
257
|
+
</div>
|
|
242
258
|
<div class="modal-f">
|
|
243
259
|
<button class="btn-ghost sm" id="mTerm">Terminal</button>
|
|
244
260
|
<button class="btn-ghost sm" id="mRefreshDiff">Refresh diff</button>
|
|
@@ -424,6 +440,8 @@ function paintModal(){
|
|
|
424
440
|
chip.style.borderColor = statusColor(r.status);
|
|
425
441
|
$('mChipTxt').textContent = r.status||'pending';
|
|
426
442
|
$('mStop').style.display = (r.status==='running'||r.status==='preparing'||r.status==='pending') ? '' : 'none';
|
|
443
|
+
// steer 는 정착한 real run 에서만 의미(드라이런은 세션 없음 — 서버가 사유와 함께 거절)
|
|
444
|
+
$('steerRow').style.display = ['done','failed','stopped'].includes(r.status) ? '' : 'none';
|
|
427
445
|
$('mTimeline').innerHTML = (r.events||[]).map(e =>
|
|
428
446
|
'<div class="ev"><span class="k">'+esc(e.kind)+'</span><span class="t">'+esc(summarize(e.kind,e.payload))+'</span></div>'
|
|
429
447
|
).join('') || '<span style="color:var(--faint)">no events yet</span>';
|
|
@@ -449,6 +467,16 @@ $('mClose').addEventListener('click', closeModal);
|
|
|
449
467
|
$('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
|
|
450
468
|
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
451
469
|
$('mRefreshDiff').addEventListener('click', loadDiff);
|
|
470
|
+
async function sendSteer(){
|
|
471
|
+
if (openRunId==null) return;
|
|
472
|
+
const msg = $('steerInput').value.trim(); if(!msg) return;
|
|
473
|
+
const res = await fetch('/api/runs/'+openRunId+'/steer',{method:'POST',
|
|
474
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({message:msg})});
|
|
475
|
+
if (res.ok){ $('steerInput').value=''; }
|
|
476
|
+
else { const j = await res.json().catch(()=>({})); alert('steer: '+(j.detail||res.status)); }
|
|
477
|
+
}
|
|
478
|
+
$('steerSend').addEventListener('click', sendSteer);
|
|
479
|
+
$('steerInput').addEventListener('keydown',(e)=>{ if(e.key==='Enter') sendSteer(); });
|
|
452
480
|
$('mStop').addEventListener('click', async ()=>{
|
|
453
481
|
if (openRunId==null) return;
|
|
454
482
|
await fetch('/api/runs/'+openRunId+'/stop',{method:'POST'});
|
|
@@ -596,6 +624,22 @@ $('taskForm').addEventListener('submit', async (e)=>{
|
|
|
596
624
|
$('taskTitle').value=''; $('taskPrompt').value='';
|
|
597
625
|
});
|
|
598
626
|
|
|
627
|
+
/* ── agent mode segmented control (mirrors hidden #taskReal) ── */
|
|
628
|
+
const segOpts = Array.from(document.querySelectorAll('#modeSeg .seg-opt'));
|
|
629
|
+
function setMode(real, persist){
|
|
630
|
+
$('taskReal').checked = real;
|
|
631
|
+
for (const b of segOpts){
|
|
632
|
+
const on = (b.dataset.real === '1') === real;
|
|
633
|
+
b.classList.toggle('on', on);
|
|
634
|
+
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
635
|
+
}
|
|
636
|
+
if (persist) try { localStorage.setItem('coxpit.real', real ? '1' : '0'); } catch {}
|
|
637
|
+
}
|
|
638
|
+
for (const b of segOpts) b.addEventListener('click', ()=>setMode(b.dataset.real === '1', true));
|
|
639
|
+
let savedMode = null;
|
|
640
|
+
try { savedMode = localStorage.getItem('coxpit.real'); } catch {}
|
|
641
|
+
setMode(savedMode === '1', false);
|
|
642
|
+
|
|
599
643
|
hydrate().then(connectWS);
|
|
600
644
|
</script>
|
|
601
645
|
</body>
|
package/src/db/index.ts
CHANGED
|
@@ -55,6 +55,7 @@ export async function ensureSchema(): Promise<void> {
|
|
|
55
55
|
branch TEXT NOT NULL DEFAULT '',
|
|
56
56
|
tmux_window TEXT NOT NULL DEFAULT '',
|
|
57
57
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
58
|
+
session_id TEXT NOT NULL DEFAULT '',
|
|
58
59
|
files_changed INTEGER NOT NULL DEFAULT 0,
|
|
59
60
|
started_at INTEGER,
|
|
60
61
|
ended_at INTEGER,
|
|
@@ -68,6 +69,7 @@ export async function ensureSchema(): Promise<void> {
|
|
|
68
69
|
ts INTEGER DEFAULT (unixepoch())
|
|
69
70
|
);
|
|
70
71
|
`);
|
|
71
|
-
// 기존 DB 마이그레이션(멱등)
|
|
72
|
+
// 기존 DB 마이그레이션(멱등)
|
|
72
73
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
|
|
74
|
+
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
73
75
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -53,6 +53,7 @@ export const agentRuns = sqliteTable('agent_runs', {
|
|
|
53
53
|
branch: text('branch').notNull().default(''),
|
|
54
54
|
tmuxWindow: text('tmux_window').notNull().default(''),
|
|
55
55
|
status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
|
|
56
|
+
sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
|
|
56
57
|
filesChanged: integer('files_changed').notNull().default(0),
|
|
57
58
|
startedAt: integer('started_at', { mode: 'timestamp' }),
|
|
58
59
|
endedAt: integer('ended_at', { mode: 'timestamp' }),
|
package/src/orchestrator.ts
CHANGED
|
@@ -126,56 +126,97 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
126
126
|
const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
|
|
127
127
|
const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
|
|
128
128
|
const cmd = `cd ${shq(wtPath)} && ${pidPrefix}{ ${agentCommand(ctx.prompt, useReal)}; }`;
|
|
129
|
-
|
|
130
|
-
liveChildren.set(runId, child);
|
|
131
|
-
|
|
132
|
-
let lastResult = '';
|
|
133
|
-
if (child.stdout) {
|
|
134
|
-
const rl = createInterface({ input: child.stdout });
|
|
135
|
-
rl.on('line', (line: string) => {
|
|
136
|
-
const s = line.trim();
|
|
137
|
-
if (!s) return;
|
|
138
|
-
let kind = 'log';
|
|
139
|
-
try {
|
|
140
|
-
const obj = JSON.parse(s) as { type?: string; result?: string };
|
|
141
|
-
if (obj.type) kind = obj.type;
|
|
142
|
-
// result 이벤트의 사람이 읽는 요약만 뽑아 둔다(없으면 원본 라인).
|
|
143
|
-
if (obj.type === 'result') lastResult = typeof obj.result === 'string' ? obj.result : s;
|
|
144
|
-
} catch { /* 비-JSON 로그 라인 */ }
|
|
145
|
-
void recordEvent(runId, kind, s.slice(0, 2000));
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
if (child.stderr) {
|
|
149
|
-
const rle = createInterface({ input: child.stderr });
|
|
150
|
-
rle.on('line', (line: string) => {
|
|
151
|
-
const s = line.trim();
|
|
152
|
-
if (s) void recordEvent(runId, 'stderr', s.slice(0, 2000));
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const code: number = await new Promise((resolve) => {
|
|
157
|
-
child.on('close', (c) => resolve(c ?? 0));
|
|
158
|
-
child.on('error', () => resolve(-1));
|
|
159
|
-
});
|
|
160
|
-
liveChildren.delete(runId);
|
|
161
|
-
|
|
162
|
-
// 4) 변경 파일 수 집계
|
|
163
|
-
const stat = await runShellOn(ctx.machine, `git -C ${shq(wtPath)} status --porcelain | wc -l`, 10000);
|
|
164
|
-
const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
|
|
165
|
-
|
|
166
|
-
const wasStopped = stoppedRuns.delete(runId);
|
|
167
|
-
await setRun(runId, {
|
|
168
|
-
status: wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed',
|
|
169
|
-
endedAt: new Date(),
|
|
170
|
-
filesChanged,
|
|
171
|
-
exitSummary: wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`,
|
|
172
|
-
});
|
|
129
|
+
await runAgentChild(runId, ctx.machine, wtPath, cmd);
|
|
173
130
|
} catch (e) {
|
|
174
131
|
await recordEvent(runId, 'error', String(e).slice(0, 500));
|
|
175
132
|
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'orchestrator error' });
|
|
176
133
|
}
|
|
177
134
|
}
|
|
178
135
|
|
|
136
|
+
/**
|
|
137
|
+
* 에이전트 자식 프로세스 배선(공용) — stream-json 파싱→이벤트, session 캡처,
|
|
138
|
+
* 종료 시 files_changed 집계 + 상태 전이. launchRun/steerRun 이 공유.
|
|
139
|
+
*/
|
|
140
|
+
async function runAgentChild(runId: number, machine: MachineTarget, wtPath: string, cmd: string): Promise<void> {
|
|
141
|
+
const child = spawnShellOn(machine, cmd);
|
|
142
|
+
liveChildren.set(runId, child);
|
|
143
|
+
|
|
144
|
+
let lastResult = '';
|
|
145
|
+
if (child.stdout) {
|
|
146
|
+
const rl = createInterface({ input: child.stdout });
|
|
147
|
+
rl.on('line', (line: string) => {
|
|
148
|
+
const s = line.trim();
|
|
149
|
+
if (!s) return;
|
|
150
|
+
let kind = 'log';
|
|
151
|
+
try {
|
|
152
|
+
const obj = JSON.parse(s) as { type?: string; result?: string; session_id?: string };
|
|
153
|
+
if (obj.type) kind = obj.type;
|
|
154
|
+
// steer(--resume) 용 세션 키 캡처
|
|
155
|
+
if (obj.type === 'system' && typeof obj.session_id === 'string') {
|
|
156
|
+
void setRun(runId, { sessionId: obj.session_id });
|
|
157
|
+
}
|
|
158
|
+
// result 이벤트의 사람이 읽는 요약만 뽑아 둔다(없으면 원본 라인).
|
|
159
|
+
if (obj.type === 'result') lastResult = typeof obj.result === 'string' ? obj.result : s;
|
|
160
|
+
} catch { /* 비-JSON 로그 라인 */ }
|
|
161
|
+
void recordEvent(runId, kind, s.slice(0, 2000));
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (child.stderr) {
|
|
165
|
+
const rle = createInterface({ input: child.stderr });
|
|
166
|
+
rle.on('line', (line: string) => {
|
|
167
|
+
const s = line.trim();
|
|
168
|
+
if (s) void recordEvent(runId, 'stderr', s.slice(0, 2000));
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const code: number = await new Promise((resolve) => {
|
|
173
|
+
child.on('close', (c) => resolve(c ?? 0));
|
|
174
|
+
child.on('error', () => resolve(-1));
|
|
175
|
+
});
|
|
176
|
+
liveChildren.delete(runId);
|
|
177
|
+
|
|
178
|
+
const stat = await runShellOn(machine, `git -C ${shq(wtPath)} status --porcelain | wc -l`, 10000);
|
|
179
|
+
const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
|
|
180
|
+
|
|
181
|
+
const wasStopped = stoppedRuns.delete(runId);
|
|
182
|
+
await setRun(runId, {
|
|
183
|
+
status: wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed',
|
|
184
|
+
endedAt: new Date(),
|
|
185
|
+
filesChanged,
|
|
186
|
+
exitSummary: wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 후속 지시(steer) — 정착한 run 의 세션을 --resume 으로 이어 같은 worktree 에서 계속.
|
|
192
|
+
* fire-and-forget. 진행 중 run 은 거부(개입은 터미널로).
|
|
193
|
+
*/
|
|
194
|
+
export async function steerRun(runId: number, message: string): Promise<{ ok: boolean; detail: string }> {
|
|
195
|
+
const ctx = await loadContext(runId);
|
|
196
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
197
|
+
const run = rr[0];
|
|
198
|
+
if (!ctx || !run) return { ok: false, detail: 'run not found' };
|
|
199
|
+
if (liveChildren.has(runId)) return { ok: false, detail: 'still running — attach the terminal to intervene' };
|
|
200
|
+
if (!['done', 'failed', 'stopped'].includes(run.status)) return { ok: false, detail: `cannot steer a '${run.status}' run` };
|
|
201
|
+
if (!run.worktreePath) return { ok: false, detail: 'worktree gone (cleaned up)' };
|
|
202
|
+
if (!run.sessionId) return { ok: false, detail: 'no agent session on this run (dry-run runs cannot be steered)' };
|
|
203
|
+
|
|
204
|
+
const wt = run.worktreePath;
|
|
205
|
+
const exists = await runShellOn(ctx.machine, `test -d ${shq(wt)} && echo yes`, 8000);
|
|
206
|
+
if (!exists.stdout.includes('yes')) return { ok: false, detail: 'worktree missing on machine' };
|
|
207
|
+
|
|
208
|
+
await setRun(runId, { status: 'running', endedAt: null });
|
|
209
|
+
await recordEvent(runId, 'steer', message.slice(0, 2000));
|
|
210
|
+
|
|
211
|
+
const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
|
|
212
|
+
const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
|
|
213
|
+
const resume = `${config.agent.bin} -p --resume ${shq(run.sessionId)} ${shq(message)}` +
|
|
214
|
+
` --output-format stream-json --verbose --permission-mode ${config.agent.perm}`;
|
|
215
|
+
const cmd = `cd ${shq(wt)} && ${pidPrefix}{ ${resume}; }`;
|
|
216
|
+
void runAgentChild(runId, ctx.machine, wt, cmd);
|
|
217
|
+
return { ok: true, detail: 'steering' };
|
|
218
|
+
}
|
|
219
|
+
|
|
179
220
|
/** 터미널 attach 용 — run 의 머신 타깃 + tmux 세션명. */
|
|
180
221
|
export async function getRunTermInfo(runId: number): Promise<{ machine: MachineTarget; session: string } | null> {
|
|
181
222
|
const ctx = await loadContext(runId);
|
package/src/server.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { db } from './db';
|
|
|
9
9
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures } from './db/schema';
|
|
10
10
|
import { BOOKMARKLET_JS } from './design';
|
|
11
11
|
import { runShellOn, shq } from './exec';
|
|
12
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo } from './orchestrator';
|
|
12
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun } from './orchestrator';
|
|
13
13
|
import { openTerm } from './term';
|
|
14
14
|
import { addSink, removeSink, broadcast } from './hub';
|
|
15
15
|
import { BOARD_HTML } from './board';
|
|
@@ -29,7 +29,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
29
29
|
app.addHook('onRequest', authGate);
|
|
30
30
|
|
|
31
31
|
// 무인증 헬스(외부 감시용)
|
|
32
|
-
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.1
|
|
32
|
+
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.2.1' }));
|
|
33
33
|
|
|
34
34
|
// 플릿 보드(단일 페이지). 인증 게이트 적용됨.
|
|
35
35
|
app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
|
|
@@ -335,6 +335,19 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
335
335
|
return res;
|
|
336
336
|
});
|
|
337
337
|
|
|
338
|
+
// 후속 지시(steer) — 정착한 run 을 같은 세션(--resume)·같은 worktree 로 계속.
|
|
339
|
+
app.post('/api/runs/:id/steer', async (req, reply) => {
|
|
340
|
+
const id = Number((req.params as { id: string }).id);
|
|
341
|
+
const b = (req.body ?? {}) as { message?: string };
|
|
342
|
+
const message = (b.message ?? '').trim();
|
|
343
|
+
if (!message) return reply.code(400).send({ error: 'message required' });
|
|
344
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
345
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
346
|
+
const res = await steerRun(id, message);
|
|
347
|
+
if (!res.ok) return reply.code(409).send(res);
|
|
348
|
+
return reply.code(202).send(res);
|
|
349
|
+
});
|
|
350
|
+
|
|
338
351
|
// 실행 중 run 중지(SIGTERM) — close 핸들러가 stopped 로 봉인.
|
|
339
352
|
app.post('/api/runs/:id/stop', async (req, reply) => {
|
|
340
353
|
const id = Number((req.params as { id: string }).id);
|
|
@@ -354,7 +367,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
354
367
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|
|
355
368
|
app.get('/ws', { websocket: true }, (socket) => {
|
|
356
369
|
addSink(socket);
|
|
357
|
-
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.1
|
|
370
|
+
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.2.1' }));
|
|
358
371
|
socket.on('close', () => removeSink(socket));
|
|
359
372
|
});
|
|
360
373
|
|