@bakapiano/ccsm 0.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 ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@bakapiano/ccsm",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
5
+ "license": "MIT",
6
+ "main": "server.js",
7
+ "bin": {
8
+ "ccsm": "./server.js"
9
+ },
10
+ "files": [
11
+ "server.js",
12
+ "lib/",
13
+ "public/",
14
+ "README.md",
15
+ "CLAUDE.md"
16
+ ],
17
+ "scripts": {
18
+ "start": "node server.js",
19
+ "dev": "node --watch server.js"
20
+ },
21
+ "dependencies": {
22
+ "express": "^4.21.2"
23
+ },
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "os": [
28
+ "win32"
29
+ ],
30
+ "keywords": [
31
+ "claude",
32
+ "claude-code",
33
+ "session-manager",
34
+ "windows",
35
+ "windows-terminal"
36
+ ],
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/bakapiano/cssm.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/bakapiano/cssm/issues"
43
+ },
44
+ "homepage": "https://github.com/bakapiano/cssm#readme",
45
+ "publishConfig": {
46
+ "access": "public",
47
+ "registry": "https://registry.npmjs.org/"
48
+ }
49
+ }
package/public/app.js ADDED
@@ -0,0 +1,551 @@
1
+ 'use strict';
2
+
3
+ const $ = (sel) => document.querySelector(sel);
4
+ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
5
+
6
+ const state = {
7
+ config: null,
8
+ sessions: [],
9
+ workspaces: [],
10
+ snapshot: null,
11
+ history: [],
12
+ autoTimer: null,
13
+ };
14
+
15
+ // ---- API helpers ----
16
+
17
+ async function api(method, url, body) {
18
+ const opts = { method, headers: { 'Content-Type': 'application/json' } };
19
+ if (body !== undefined) opts.body = JSON.stringify(body);
20
+ const r = await fetch(url, opts);
21
+ const text = await r.text();
22
+ let json;
23
+ try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
24
+ if (!r.ok) throw new Error(json.error || `HTTP ${r.status}`);
25
+ return json;
26
+ }
27
+
28
+ // ---- toast ----
29
+
30
+ const toastEl = $('#toast');
31
+ let toastT;
32
+ function toast(msg, kind = 'ok') {
33
+ toastEl.textContent = msg;
34
+ toastEl.className = `toast show ${kind}`;
35
+ clearTimeout(toastT);
36
+ toastT = setTimeout(() => toastEl.classList.remove('show'), 3000);
37
+ }
38
+
39
+ // ---- formatting ----
40
+
41
+ function fmtTime(ms) {
42
+ if (!ms) return '—';
43
+ const d = new Date(ms);
44
+ return d.toLocaleString(undefined, { hour12: false });
45
+ }
46
+ function fmtAgo(ms) {
47
+ if (!ms) return '—';
48
+ const sec = Math.floor((Date.now() - ms) / 1000);
49
+ if (sec < 60) return `${sec}s ago`;
50
+ if (sec < 3600) return `${Math.floor(sec/60)}m ago`;
51
+ if (sec < 86400) return `${Math.floor(sec/3600)}h ago`;
52
+ return `${Math.floor(sec/86400)}d ago`;
53
+ }
54
+ function escapeHtml(s) {
55
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
56
+ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;',
57
+ }[c]));
58
+ }
59
+
60
+ // ---- sessions render ----
61
+
62
+ function renderSessions() {
63
+ const tb = $('#sessionsTable tbody');
64
+ tb.innerHTML = '';
65
+ for (const s of state.sessions) {
66
+ const tr = document.createElement('tr');
67
+ tr.innerHTML = `
68
+ <td><span class="status-dot ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}"></span></td>
69
+ <td><div class="ellipsis" title="${escapeHtml(s.title || '')}">${escapeHtml(s.title || '(no title)')}</div>
70
+ <div class="mono small" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0,8))}…</div></td>
71
+ <td><div class="ellipsis mono" title="${escapeHtml(s.cwd)}">${escapeHtml(s.cwd)}</div></td>
72
+ <td title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
73
+ <td title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
74
+ <td class="mono">${escapeHtml(String(s.pid))}</td>
75
+ <td style="text-align:right; white-space:nowrap;">
76
+ <button class="btn small btn-primary" data-focus="${escapeHtml(s.sessionId)}" title="bring the wt window already hosting this session to the foreground">focus</button>
77
+ <button class="btn small" data-resume="${escapeHtml(s.sessionId)}" data-cwd="${escapeHtml(s.cwd)}" title="open a NEW wt window with claude --resume">resume new</button>
78
+ </td>
79
+ `;
80
+ tb.appendChild(tr);
81
+ }
82
+ $('#sessionsMeta').textContent =
83
+ state.sessions.length ? `${state.sessions.length} live · last refresh ${new Date().toLocaleTimeString()}` : 'no live sessions';
84
+ }
85
+
86
+ // ---- snapshot render ----
87
+
88
+ function renderSnapshot() {
89
+ const snap = state.snapshot;
90
+ if (!snap) {
91
+ $('#snapshotMeta').textContent = 'no snapshot yet';
92
+ $('#snapshotPreview').textContent = '';
93
+ return;
94
+ }
95
+ $('#snapshotMeta').textContent =
96
+ `${snap.sessions.length} session(s) — taken ${fmtAgo(snap.takenAt)} (${fmtTime(snap.takenAt)})`;
97
+ const lines = snap.sessions.map((s) =>
98
+ `${(s.title || s.sessionId.slice(0,8)).padEnd(40).slice(0,40)} ${s.cwd}`
99
+ );
100
+ $('#snapshotPreview').textContent = lines.join('\n');
101
+
102
+ const sel = $('#historySelect');
103
+ sel.innerHTML = '<option value="">history…</option>' +
104
+ state.history.map((h) => `<option value="${escapeHtml(h.file)}">${escapeHtml(h.file.replace('.json',''))}</option>`).join('');
105
+ }
106
+
107
+ // ---- workspaces render ----
108
+
109
+ function renderWorkspaces() {
110
+ const ul = $('#workspaceList');
111
+ ul.innerHTML = '';
112
+ if (state.workspaces.length === 0) {
113
+ ul.innerHTML = '<div class="muted small">no workspaces under workDir yet — first new-session will create one</div>';
114
+ }
115
+ for (const w of state.workspaces) {
116
+ const repoTags = w.repos.map((r) =>
117
+ `<span class="tag ${r.cloned ? 'ok' : ''}">${escapeHtml(r.name)}${r.cloned ? ' ✓' : ''}</span>`
118
+ ).join(' ');
119
+ const card = document.createElement('div');
120
+ card.className = 'workspace-card' + (w.inUse ? ' in-use' : '');
121
+ card.innerHTML = `
122
+ <div>
123
+ <div class="name">${escapeHtml(w.name)}
124
+ ${w.inUse ? `<span class="tag warn">in use × ${w.sessionsHere.length}</span>` : '<span class="tag ok">free</span>'}
125
+ </div>
126
+ <div class="repos">${escapeHtml(w.path)}</div>
127
+ <div style="margin-top:4px;">${repoTags}</div>
128
+ </div>
129
+ `;
130
+ ul.appendChild(card);
131
+ }
132
+
133
+ const sel = $('#workspaceSelect');
134
+ sel.innerHTML = '<option value="">(auto — find or create unused)</option>' +
135
+ state.workspaces.filter((w) => !w.inUse).map((w) =>
136
+ `<option value="${escapeHtml(w.name)}">${escapeHtml(w.name)}</option>`
137
+ ).join('');
138
+ }
139
+
140
+ // ---- repo picker render (for "new session") ----
141
+
142
+ function renderRepoPicker() {
143
+ const root = $('#repoPicker');
144
+ root.innerHTML = '';
145
+ for (const r of (state.config?.repos || [])) {
146
+ const id = `repo_${r.name}`;
147
+ const chip = document.createElement('label');
148
+ chip.className = 'repo-chip' + (r.defaultSelected ? ' checked' : '');
149
+ chip.innerHTML = `<input type="checkbox" id="${id}" data-repo="${escapeHtml(r.name)}" ${r.defaultSelected ? 'checked' : ''}/>${escapeHtml(r.name)}`;
150
+ chip.querySelector('input').addEventListener('change', (e) => {
151
+ chip.classList.toggle('checked', e.target.checked);
152
+ });
153
+ root.appendChild(chip);
154
+ }
155
+ }
156
+
157
+ // ---- config form render ----
158
+
159
+ function renderConfig() {
160
+ if (!state.config) return;
161
+ $('#cfgPort').value = state.config.port;
162
+ $('#cfgWorkDir').value = state.config.workDir;
163
+ $('#cfgInterval').value = state.config.snapshotIntervalMs;
164
+ $('#cfgKeep').value = state.config.snapshotHistoryKeep;
165
+ $('#cfgClaudeCommand').value = state.config.claudeCommand || 'claude';
166
+ $('#cfgCommandShell').value = state.config.commandShell || 'pwsh';
167
+ $('#cfgAutoFocus').checked = state.config.autoFocusOnLaunch !== false;
168
+ const termSel = $('#cfgTerminal');
169
+ termSel.innerHTML = (state.terminals || []).map((t) =>
170
+ `<option value="${escapeHtml(t.name)}" ${t.name === state.config.terminal ? 'selected' : ''}>${escapeHtml(t.name)} (${escapeHtml(t.processName)})</option>`
171
+ ).join('');
172
+ $('#cfgFinderPrompt').value = state.config.finderPrompt || '';
173
+
174
+ const tb = $('#reposTable tbody');
175
+ tb.innerHTML = '';
176
+ (state.config.repos || []).forEach((r, idx) => {
177
+ const tr = document.createElement('tr');
178
+ tr.innerHTML = `
179
+ <td><input type="text" value="${escapeHtml(r.name)}" data-field="name" data-idx="${idx}" style="width:140px;" /></td>
180
+ <td><input type="text" value="${escapeHtml(r.url)}" data-field="url" data-idx="${idx}" style="width:100%;" /></td>
181
+ <td style="text-align:center;"><input type="checkbox" data-field="defaultSelected" data-idx="${idx}" ${r.defaultSelected ? 'checked' : ''} /></td>
182
+ <td style="text-align:right;"><button class="btn small btn-danger" data-remove-repo="${idx}">remove</button></td>
183
+ `;
184
+ tb.appendChild(tr);
185
+ });
186
+ }
187
+
188
+ function readConfigFromForm() {
189
+ const repos = $$('#reposTable tbody tr').map((tr) => {
190
+ const inputs = tr.querySelectorAll('input');
191
+ return {
192
+ name: inputs[0].value.trim(),
193
+ url: inputs[1].value.trim(),
194
+ defaultSelected: inputs[2].checked,
195
+ };
196
+ }).filter((r) => r.name && r.url);
197
+
198
+ return {
199
+ port: Number($('#cfgPort').value) || 7777,
200
+ workDir: $('#cfgWorkDir').value.trim(),
201
+ snapshotIntervalMs: Math.max(5000, Number($('#cfgInterval').value) || 60000),
202
+ snapshotHistoryKeep: Math.max(1, Number($('#cfgKeep').value) || 30),
203
+ claudeCommand: ($('#cfgClaudeCommand').value || 'claude').trim(),
204
+ terminal: $('#cfgTerminal').value || 'wt',
205
+ commandShell: $('#cfgCommandShell').value || 'pwsh',
206
+ autoFocusOnLaunch: $('#cfgAutoFocus').checked,
207
+ finderPrompt: $('#cfgFinderPrompt').value,
208
+ repos,
209
+ };
210
+ }
211
+
212
+ // ---- data fetching ----
213
+
214
+ async function loadSessions() {
215
+ const r = await api('GET', '/api/sessions');
216
+ state.sessions = r.sessions;
217
+ renderSessions();
218
+ }
219
+
220
+ async function loadConfig() {
221
+ const [cfg, terminals] = await Promise.all([
222
+ api('GET', '/api/config'),
223
+ api('GET', '/api/terminals'),
224
+ ]);
225
+ state.config = cfg;
226
+ state.terminals = terminals.terminals;
227
+ renderConfig();
228
+ renderRepoPicker();
229
+ $('#serverInfo').textContent =
230
+ `port ${state.config.port} · workDir ${state.config.workDir} · terminal ${state.config.terminal} · ${state.config.claudeCommand}`;
231
+ }
232
+
233
+ async function loadSnapshot() {
234
+ const r = await api('GET', '/api/snapshot');
235
+ state.snapshot = r.snapshot;
236
+ const h = await api('GET', '/api/snapshot/history');
237
+ state.history = h.history;
238
+ renderSnapshot();
239
+ }
240
+
241
+ async function loadWorkspaces() {
242
+ const r = await api('GET', '/api/workspaces');
243
+ state.workspaces = r.workspaces;
244
+ renderWorkspaces();
245
+ }
246
+
247
+ async function refreshAll() {
248
+ await Promise.all([loadSessions(), loadSnapshot(), loadWorkspaces()]);
249
+ }
250
+
251
+ // ---- event wiring ----
252
+
253
+ function wireUp() {
254
+ $('#refreshBtn').onclick = () => refreshAll().then(() => toast('refreshed'));
255
+
256
+ $('#autoRefresh').onchange = (e) => {
257
+ if (e.target.checked) startAutoRefresh();
258
+ else stopAutoRefresh();
259
+ };
260
+
261
+ $('#sessionsTable').addEventListener('click', async (ev) => {
262
+ const focusBtn = ev.target.closest('button[data-focus]');
263
+ if (focusBtn) {
264
+ const sessionId = focusBtn.dataset.focus;
265
+ focusBtn.disabled = true;
266
+ try {
267
+ const r = await api('POST', `/api/sessions/${sessionId}/focus`);
268
+ if (r.ok && r.activated) {
269
+ toast(`focused: ${r.windowTitle || r.windowProcess || sessionId.slice(0,8)}`);
270
+ } else if (r.ok) {
271
+ toast(`window found but Windows blocked focus (${r.windowProcess}); try clicking the wt taskbar icon`, 'error');
272
+ } else {
273
+ toast(`no window for pid — chain: ${(r.chain||[]).map(c=>c.name).join('→')}`, 'error');
274
+ }
275
+ } catch (e) {
276
+ toast(e.message, 'error');
277
+ } finally {
278
+ focusBtn.disabled = false;
279
+ }
280
+ return;
281
+ }
282
+ const btn = ev.target.closest('button[data-resume]');
283
+ if (!btn) return;
284
+ const sessionId = btn.dataset.resume;
285
+ const cwd = btn.dataset.cwd;
286
+ btn.disabled = true;
287
+ try {
288
+ await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
289
+ toast(`opening wt for ${sessionId.slice(0,8)}…`);
290
+ } catch (e) {
291
+ toast(e.message, 'error');
292
+ } finally {
293
+ btn.disabled = false;
294
+ }
295
+ });
296
+
297
+ $('#finderBtn').onclick = async () => {
298
+ try {
299
+ await api('POST', '/api/sessions/finder');
300
+ toast('finder session launching in a new wt window');
301
+ } catch (e) {
302
+ toast(e.message, 'error');
303
+ }
304
+ };
305
+
306
+ $('#snapshotSaveBtn').onclick = async () => {
307
+ try {
308
+ const r = await api('POST', '/api/snapshot');
309
+ state.snapshot = r.snapshot;
310
+ const h = await api('GET', '/api/snapshot/history');
311
+ state.history = h.history;
312
+ renderSnapshot();
313
+ toast(`saved snapshot with ${r.snapshot.sessions.length} session(s)`);
314
+ } catch (e) {
315
+ toast(e.message, 'error');
316
+ }
317
+ };
318
+
319
+ $('#snapshotRestoreBtn').onclick = async () => {
320
+ const snap = state.snapshot;
321
+ if (!snap || !snap.sessions.length) return toast('no sessions in snapshot', 'error');
322
+ if (!confirm(`Restore ${snap.sessions.length} session(s)? Each opens a new wt window.`)) return;
323
+ try {
324
+ const r = await api('POST', '/api/snapshot/restore');
325
+ toast(`launched ${r.restored.launched.length} / ${r.count}`);
326
+ } catch (e) {
327
+ toast(e.message, 'error');
328
+ }
329
+ };
330
+
331
+ $('#historyRestoreBtn').onclick = async () => {
332
+ const file = $('#historySelect').value;
333
+ if (!file) return toast('pick a history snapshot first', 'error');
334
+ if (!confirm(`Restore from ${file}?`)) return;
335
+ try {
336
+ const r = await api('POST', '/api/snapshot/restore', { file });
337
+ toast(`launched ${r.restored.launched.length} / ${r.count}`);
338
+ } catch (e) {
339
+ toast(e.message, 'error');
340
+ }
341
+ };
342
+
343
+ $('#newSessionBtn').onclick = async () => {
344
+ const repos = $$('#repoPicker input:checked').map((i) => i.dataset.repo);
345
+ if (repos.length === 0) return toast('select at least one repo', 'error');
346
+ const workspace = $('#workspaceSelect').value || undefined;
347
+ const btn = $('#newSessionBtn');
348
+ btn.disabled = true;
349
+ $('#newSessionResult').textContent = '';
350
+ resetProgress(repos);
351
+ try {
352
+ const result = await streamNewSession({ repos, workspace });
353
+ if (result.success) {
354
+ const ws = result.workspace;
355
+ const summary = (result.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
356
+ $('#newSessionResult').textContent =
357
+ `launched in ${ws.path}${result.created ? ' (newly created)' : ''} — ${summary}`;
358
+ toast(`launched new session in ${ws.name}`);
359
+ } else {
360
+ $('#newSessionResult').textContent = `error: ${result.error}`;
361
+ toast(result.error || 'new session failed', 'error');
362
+ }
363
+ await loadWorkspaces();
364
+ } catch (e) {
365
+ $('#newSessionResult').textContent = `error: ${e.message}`;
366
+ toast(e.message, 'error');
367
+ } finally {
368
+ btn.disabled = false;
369
+ }
370
+ };
371
+
372
+ $('#saveConfigBtn').onclick = async () => {
373
+ const next = readConfigFromForm();
374
+ try {
375
+ const cfg = await api('PUT', '/api/config', next);
376
+ state.config = cfg;
377
+ renderConfig();
378
+ renderRepoPicker();
379
+ $('#configSavedAt').textContent = `saved at ${new Date().toLocaleTimeString()}`;
380
+ toast('config saved');
381
+ await loadWorkspaces();
382
+ } catch (e) {
383
+ toast(e.message, 'error');
384
+ }
385
+ };
386
+
387
+ $('#addRepoBtn').onclick = () => {
388
+ state.config.repos.push({ name: '', url: '', defaultSelected: false });
389
+ renderConfig();
390
+ };
391
+
392
+ $('#reposTable').addEventListener('click', (ev) => {
393
+ const rm = ev.target.closest('button[data-remove-repo]');
394
+ if (!rm) return;
395
+ const idx = Number(rm.dataset.removeRepo);
396
+ state.config.repos.splice(idx, 1);
397
+ renderConfig();
398
+ });
399
+ }
400
+
401
+ // ---- auto refresh ----
402
+
403
+ function startAutoRefresh() {
404
+ stopAutoRefresh();
405
+ state.autoTimer = setInterval(() => {
406
+ loadSessions().catch(() => {});
407
+ loadSnapshot().catch(() => {});
408
+ }, 5000);
409
+ }
410
+ function stopAutoRefresh() {
411
+ if (state.autoTimer) { clearInterval(state.autoTimer); state.autoTimer = null; }
412
+ }
413
+
414
+ // ---- NDJSON streaming for /api/sessions/new ----
415
+
416
+ function resetProgress(repoNames) {
417
+ const root = $('#newSessionProgress');
418
+ root.innerHTML = '';
419
+ for (const r of repoNames) {
420
+ const el = document.createElement('div');
421
+ el.className = 'progress-item';
422
+ el.dataset.repo = r;
423
+ el.innerHTML = `
424
+ <div class="head">
425
+ <span class="name">${escapeHtml(r)}</span>
426
+ <span class="phase">queued</span>
427
+ <span class="pct"></span>
428
+ </div>
429
+ <div class="progress-bar"><div class="fill"></div></div>
430
+ <div class="detail"></div>
431
+ `;
432
+ root.appendChild(el);
433
+ }
434
+ }
435
+
436
+ function progressItem(repo) {
437
+ return document.querySelector(`#newSessionProgress .progress-item[data-repo="${CSS.escape(repo)}"]`);
438
+ }
439
+
440
+ function setProgress(repo, { phase, percent, detail, state, indeterminate } = {}) {
441
+ const el = progressItem(repo);
442
+ if (!el) return;
443
+ if (state) {
444
+ el.classList.remove('ok', 'error');
445
+ if (state === 'ok' || state === 'error') el.classList.add(state);
446
+ }
447
+ if (phase != null) el.querySelector('.phase').textContent = phase;
448
+ if (percent != null) {
449
+ el.querySelector('.pct').textContent = `${percent}%`;
450
+ el.querySelector('.fill').style.width = `${percent}%`;
451
+ el.querySelector('.fill').classList.remove('indeterminate');
452
+ }
453
+ if (indeterminate) {
454
+ el.querySelector('.fill').classList.add('indeterminate');
455
+ el.querySelector('.pct').textContent = '';
456
+ }
457
+ if (detail != null) el.querySelector('.detail').textContent = detail;
458
+ }
459
+
460
+ async function streamNewSession(body) {
461
+ const res = await fetch('/api/sessions/new', {
462
+ method: 'POST',
463
+ headers: { 'Content-Type': 'application/json' },
464
+ body: JSON.stringify(body),
465
+ });
466
+ if (!res.ok && res.headers.get('content-type')?.startsWith('application/json')) {
467
+ const j = await res.json();
468
+ throw new Error(j.error || `HTTP ${res.status}`);
469
+ }
470
+ const reader = res.body.getReader();
471
+ const decoder = new TextDecoder();
472
+ let buf = '';
473
+ let final = null;
474
+ while (true) {
475
+ const { done, value } = await reader.read();
476
+ if (done) break;
477
+ buf += decoder.decode(value, { stream: true });
478
+ const lines = buf.split('\n');
479
+ buf = lines.pop();
480
+ for (const line of lines) {
481
+ if (!line.trim()) continue;
482
+ let event;
483
+ try { event = JSON.parse(line); } catch { continue; }
484
+ handleNewSessionEvent(event);
485
+ if (event.type === 'done') final = event;
486
+ }
487
+ }
488
+ if (buf.trim()) {
489
+ try {
490
+ const event = JSON.parse(buf);
491
+ handleNewSessionEvent(event);
492
+ if (event.type === 'done') final = event;
493
+ } catch {}
494
+ }
495
+ return final || { success: false, error: 'stream ended unexpectedly' };
496
+ }
497
+
498
+ function handleNewSessionEvent(ev) {
499
+ switch (ev.type) {
500
+ case 'workspace':
501
+ $('#newSessionResult').textContent =
502
+ `workspace: ${ev.workspace.path}${ev.created ? ' (new)' : ''}`;
503
+ break;
504
+ case 'clone-start':
505
+ setProgress(ev.repo, { phase: 'starting', indeterminate: true });
506
+ break;
507
+ case 'clone-progress':
508
+ setProgress(ev.repo, {
509
+ phase: ev.phase,
510
+ percent: ev.percent,
511
+ detail: ev.detail || (ev.current != null ? `${ev.current}/${ev.total}` : ''),
512
+ });
513
+ break;
514
+ case 'clone-end':
515
+ if (ev.ok) {
516
+ setProgress(ev.repo, {
517
+ phase: ev.action || 'done',
518
+ percent: 100,
519
+ detail: ev.path || '',
520
+ state: 'ok',
521
+ });
522
+ } else {
523
+ setProgress(ev.repo, {
524
+ phase: 'error',
525
+ detail: ev.error,
526
+ state: 'error',
527
+ });
528
+ }
529
+ break;
530
+ case 'launched':
531
+ $('#newSessionResult').textContent =
532
+ `terminal launching — pid ${ev.launched.pid} (${ev.launched.terminal})`;
533
+ break;
534
+ case 'done':
535
+ // handled by caller
536
+ break;
537
+ }
538
+ }
539
+
540
+ // ---- boot ----
541
+
542
+ (async () => {
543
+ wireUp();
544
+ try {
545
+ await loadConfig();
546
+ await refreshAll();
547
+ startAutoRefresh();
548
+ } catch (e) {
549
+ toast('initial load failed: ' + e.message, 'error');
550
+ }
551
+ })();