@bakapiano/ccsm 0.18.2 → 0.18.3

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/lib/tunnel.js CHANGED
@@ -17,10 +17,12 @@
17
17
  // dirs. Returns the absolute path so we can spawn the child regardless
18
18
  // of whether the post-install PATH refresh has reached this Node process.
19
19
 
20
- const { spawn, execFileSync } = require('node:child_process');
20
+ const { spawn, execFile } = require('node:child_process');
21
21
  const path = require('node:path');
22
22
  const fs = require('node:fs');
23
23
  const os = require('node:os');
24
+ const { promisify } = require('node:util');
25
+ const execFileP = promisify(execFile);
24
26
 
25
27
  const PROVIDERS = {
26
28
  cloudflared: {
@@ -61,19 +63,15 @@ let token = null; // Remote-access bearer token. Null = no remote
61
63
  function getToken() { return token; }
62
64
  function setToken(t) { token = t ? String(t) : null; return token; }
63
65
 
64
- function findBinary(provider) {
66
+ async function findBinary(provider) {
65
67
  const p = PROVIDERS[provider];
66
68
  if (!p) return null;
67
69
  // PATH lookup via where.exe — works regardless of how the CLI got
68
- // installed (winget, choco, manual, in-tree).
70
+ // installed (winget, choco, manual, in-tree). windowsHide stops the
71
+ // conhost window from flashing.
69
72
  try {
70
- // windowsHide: true stops each execFileSync from briefly flashing
71
- // a black conhost window. The Remote page polls /api/tunnel/status
72
- // every 2.5s and probe() (when its 30s cache is cold) fires 4-6
73
- // execs back-to-back — without this flag the screen popcorns with
74
- // little windows every cache cycle.
75
- const out = execFileSync('where.exe', [p.binary], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true })
76
- .toString().trim().split(/\r?\n/)[0];
73
+ const { stdout } = await execFileP('where.exe', [p.binary], { windowsHide: true });
74
+ const out = String(stdout).trim().split(/\r?\n/)[0];
77
75
  if (out && fs.existsSync(out)) return out;
78
76
  } catch { /* not on PATH */ }
79
77
  // Fall back to known install locations (winget's PATH update doesn't
@@ -97,20 +95,18 @@ function findBinary(provider) {
97
95
  return null;
98
96
  }
99
97
 
100
- function getVersion(provider, exe) {
98
+ async function getVersion(provider, exe) {
101
99
  try {
102
- const out = execFileSync(exe, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
103
- .toString().trim().split(/\r?\n/)[0];
104
- return out || null;
100
+ const { stdout } = await execFileP(exe, ['--version'], { windowsHide: true });
101
+ return String(stdout).trim().split(/\r?\n/)[0] || null;
105
102
  } catch { return null; }
106
103
  }
107
104
 
108
- function checkDevtunnelLogin(exe) {
105
+ async function checkDevtunnelLogin(exe) {
109
106
  try {
110
- const out = execFileSync(exe, ['user', 'show'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true })
111
- .toString().trim();
107
+ const { stdout } = await execFileP(exe, ['user', 'show'], { windowsHide: true, timeout: 5000 });
112
108
  // "Logged in as <email> using <provider>." vs "Not logged in"
113
- const m = out.match(/Logged in as (\S+)/);
109
+ const m = String(stdout).trim().match(/Logged in as (\S+)/);
114
110
  if (m) return { loggedIn: true, user: m[1] };
115
111
  return { loggedIn: false, user: null };
116
112
  } catch {
@@ -128,31 +124,39 @@ const PROBE_TTL_MS = 30_000;
128
124
  let probeCache = null;
129
125
  let probeCacheAt = 0;
130
126
 
131
- function probe(force = false) {
127
+ async function probe(force = false) {
132
128
  if (!force && probeCache && Date.now() - probeCacheAt < PROBE_TTL_MS) {
133
129
  return probeCache;
134
130
  }
135
- const out = {};
136
- for (const id of Object.keys(PROVIDERS)) {
137
- const exe = findBinary(id);
138
- const p = { installed: !!exe, exe, version: exe ? getVersion(id, exe) : null };
139
- if (id === 'devtunnel' && exe) {
140
- Object.assign(p, checkDevtunnelLogin(exe));
131
+ // All providers in parallel; within each, --version and the
132
+ // devtunnel `user show` check run together too. Cold probe drops
133
+ // from ~1.5s serial to ~700ms (capped by the slowest exec —
134
+ // typically `devtunnel user show`).
135
+ const ids = Object.keys(PROVIDERS);
136
+ const results = await Promise.all(ids.map(async (id) => {
137
+ const exe = await findBinary(id);
138
+ const p = { installed: !!exe, exe, version: null };
139
+ if (exe) {
140
+ const tasks = [getVersion(id, exe)];
141
+ if (id === 'devtunnel') tasks.push(checkDevtunnelLogin(exe));
142
+ const [version, devUser] = await Promise.all(tasks);
143
+ p.version = version;
144
+ if (devUser) Object.assign(p, devUser);
141
145
  }
142
- out[id] = p;
143
- }
144
- probeCache = out;
146
+ return [id, p];
147
+ }));
148
+ probeCache = Object.fromEntries(results);
145
149
  probeCacheAt = Date.now();
146
- return out;
150
+ return probeCache;
147
151
  }
148
152
 
149
153
  // Invalidate the cache when callers know the on-disk state likely changed
150
154
  // (post-install, post-login, etc.). Next probe() re-shells.
151
155
  function invalidateProbe() { probeCache = null; probeCacheAt = 0; }
152
156
 
153
- function status() {
157
+ async function status() {
154
158
  return {
155
- providers: probe(),
159
+ providers: await probe(),
156
160
  running: !!current,
157
161
  provider: current?.provider || null,
158
162
  url: current?.url || null,
@@ -174,10 +178,10 @@ async function start({ provider, port }) {
174
178
  if (current) throw new Error('tunnel already running');
175
179
  const p = PROVIDERS[provider];
176
180
  if (!p) throw new Error(`unknown provider: ${provider}`);
177
- const exe = findBinary(provider);
181
+ const exe = await findBinary(provider);
178
182
  if (!exe) throw new Error(`${p.label} is not installed`);
179
183
  if (provider === 'devtunnel') {
180
- const { loggedIn } = checkDevtunnelLogin(exe);
184
+ const { loggedIn } = await checkDevtunnelLogin(exe);
181
185
  if (!loggedIn) throw new Error('devtunnel requires login — run `devtunnel user login` first');
182
186
  }
183
187
 
@@ -211,7 +215,7 @@ async function start({ provider, port }) {
211
215
  // Wait up to 25s for the URL to show up in stdout.
212
216
  const deadline = Date.now() + 25_000;
213
217
  while (Date.now() < deadline) {
214
- if (entry.url) return status();
218
+ if (entry.url) return await status();
215
219
  if (!current || current !== entry) {
216
220
  throw new Error('tunnel exited before reporting a URL · ' + entry.log.slice(-3).join(' / '));
217
221
  }
@@ -219,7 +223,7 @@ async function start({ provider, port }) {
219
223
  }
220
224
  // Timed out — keep the child alive (the URL might appear later) but
221
225
  // tell the caller we don't have one yet.
222
- return status();
226
+ return await status();
223
227
  }
224
228
 
225
229
  function stop() {
@@ -138,6 +138,17 @@ function attach(id, ws) {
138
138
  try { ws.close(4404, 'no such terminal'); } catch {}
139
139
  return;
140
140
  }
141
+ // Latest-wins: a session has at most one live WebSocket at any
142
+ // moment. The new attach displaces any existing ones — keeps PTY
143
+ // resize semantics unambiguous (each client sends its own dimensions
144
+ // for its own viewport; with two clients fighting, claude's TUI
145
+ // re-renders for whichever sent last and the other side sees a
146
+ // mis-sized layout). Close code 4001 + reason lets the displaced
147
+ // client show a clearer message than the generic "[disconnected]".
148
+ for (const other of entry.sockets) {
149
+ try { other.close(4001, 'displaced by another client'); } catch {}
150
+ }
151
+ entry.sockets.clear();
141
152
  entry.sockets.add(ws);
142
153
  if (entry.history) {
143
154
  try { ws.send(JSON.stringify({ type: 'output', data: scrubReplayResponses(entry.history) })); } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.18.2",
3
+ "version": "0.18.3",
4
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
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -440,3 +440,57 @@
440
440
  background: #faf9f5;
441
441
  border-color: #faf9f5;
442
442
  }
443
+
444
+ /* Displaced state — shown when the server kicks us off because another
445
+ client attached to the same session (latest-wins). Same dark surface
446
+ as terminal-empty so the transition from running terminal → displaced
447
+ doesn't flash a colour change. */
448
+ .terminal-displaced {
449
+ background: #1a1815;
450
+ color: #e8e3d5;
451
+ display: flex;
452
+ align-items: center;
453
+ justify-content: center;
454
+ height: 100%;
455
+ padding: var(--s-5);
456
+ }
457
+ .terminal-displaced-card {
458
+ max-width: 460px;
459
+ text-align: center;
460
+ display: flex;
461
+ flex-direction: column;
462
+ gap: var(--s-3);
463
+ }
464
+ .terminal-displaced-card h2 {
465
+ margin: 0;
466
+ font-size: 16px;
467
+ font-weight: 600;
468
+ color: #faf9f5;
469
+ letter-spacing: -0.005em;
470
+ }
471
+ .terminal-displaced-card p {
472
+ margin: 0;
473
+ font-size: 13px;
474
+ line-height: 1.55;
475
+ color: rgba(232, 227, 213, 0.72);
476
+ }
477
+ .terminal-displaced-actions {
478
+ margin-top: var(--s-2);
479
+ display: flex;
480
+ justify-content: center;
481
+ }
482
+ .terminal-displaced-card .action.primary {
483
+ background: #e8e3d5;
484
+ color: #1a1815;
485
+ border-color: #e8e3d5;
486
+ padding: 9px 20px;
487
+ font-size: 13px;
488
+ }
489
+ .terminal-displaced-card .action.primary:hover {
490
+ background: #faf9f5;
491
+ border-color: #faf9f5;
492
+ }
493
+ .terminal-displaced-hint {
494
+ font-size: 11.5px !important;
495
+ color: rgba(232, 227, 213, 0.45) !important;
496
+ }
@@ -1724,6 +1724,27 @@
1724
1724
  word-break: break-all;
1725
1725
  }
1726
1726
 
1727
+ .remote-loading {
1728
+ display: flex;
1729
+ flex-direction: column;
1730
+ align-items: center;
1731
+ justify-content: center;
1732
+ gap: var(--s-3);
1733
+ min-height: 260px;
1734
+ color: var(--ink-muted);
1735
+ font-size: 13px;
1736
+ }
1737
+ .remote-loading p { margin: 0; }
1738
+ .remote-loading-spinner {
1739
+ width: 28px;
1740
+ height: 28px;
1741
+ border-radius: 50%;
1742
+ border: 2px solid var(--border-strong);
1743
+ border-top-color: var(--ink);
1744
+ animation: remote-spin 0.75s linear infinite;
1745
+ }
1746
+ @keyframes remote-spin { to { transform: rotate(360deg); } }
1747
+
1727
1748
  .remote-empty {
1728
1749
  margin: 0;
1729
1750
  font-size: 12.5px;
@@ -3,7 +3,7 @@
3
3
  // output frames into xterm. Disposes everything on unmount or id change.
4
4
 
5
5
  import { html } from '../html.js';
6
- import { useEffect, useRef } from 'preact/hooks';
6
+ import { useEffect, useRef, useState } from 'preact/hooks';
7
7
  import { Terminal } from '@xterm/xterm';
8
8
  import { FitAddon } from '@xterm/addon-fit';
9
9
  import { WebLinksAddon } from '@xterm/addon-web-links';
@@ -36,6 +36,13 @@ export function TerminalView({ terminalId }) {
36
36
  const hostRef = useRef(null);
37
37
  const termRef = useRef(null);
38
38
  const wsRef = useRef(null);
39
+ // Set when ws.onclose receives our custom "displaced by another
40
+ // client" code (4001) from lib/webTerminal.js's latest-wins policy.
41
+ // Renders a full-pane prompt with a "Take it back" button that bumps
42
+ // reattachNonce → useEffect re-runs → new WS, displacing whoever
43
+ // currently holds the session.
44
+ const [displaced, setDisplaced] = useState(false);
45
+ const [reattachNonce, setReattach] = useState(0);
39
46
 
40
47
  useEffect(() => {
41
48
  if (!terminalId || !hostRef.current) return;
@@ -148,8 +155,19 @@ export function TerminalView({ terminalId }) {
148
155
  term.write(`\r\n\x1b[2m[process exited · code ${frame.code}]\x1b[0m\r\n`);
149
156
  }
150
157
  };
151
- ws.onclose = () => {
152
- term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
158
+ ws.onclose = (ev) => {
159
+ // Server uses code 4001 + reason "displaced by another client"
160
+ // when a fresh attach takes over the session (latest-wins policy
161
+ // in lib/webTerminal.js's attach). We replace the terminal with
162
+ // a full-pane prompt + Take it back button via setDisplaced(true).
163
+ // Generic disconnects (network blip, server restart, PTY exit)
164
+ // get the dim inline notice as before — those usually self-heal
165
+ // and aren't worth a modal.
166
+ if (ev && ev.code === 4001) {
167
+ setDisplaced(true);
168
+ } else {
169
+ term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
170
+ }
153
171
  };
154
172
 
155
173
  const onData = (data) => {
@@ -328,10 +346,50 @@ export function TerminalView({ terminalId }) {
328
346
  termRef.current = null;
329
347
  wsRef.current = null;
330
348
  };
331
- }, [terminalId]);
349
+ }, [terminalId, reattachNonce]);
332
350
 
333
351
  if (!terminalId) {
334
352
  return html`<div class="terminal-empty">Select a terminal on the left, or launch a new one.</div>`;
335
353
  }
336
- return html`<div ref=${hostRef} class="terminal-host"></div>`;
354
+ if (displaced) {
355
+ // Distinct key (and a non-div tag) forces Preact's reconciler to
356
+ // UNMOUNT the host <div> and mount a fresh element. Without this,
357
+ // Preact reuses the same DOM node, only flipping its className —
358
+ // and the xterm canvases stay parented inside, visible behind our
359
+ // overlay text. Re-mount on reattach: bumping reattachNonce reruns
360
+ // the effect with a fresh Terminal + WebSocket pair, which the
361
+ // server's latest-wins gate handles by displacing the current
362
+ // holder.
363
+ return html`
364
+ <section key="displaced" class="terminal-displaced">
365
+ <div class="terminal-displaced-card">
366
+ <h2>Another device picked up this session</h2>
367
+ <p>
368
+ Only one client at a time can attach. Your terminal here was
369
+ closed when another browser opened this session — its keystrokes
370
+ and resize events would otherwise fight yours.
371
+ </p>
372
+ <div class="terminal-displaced-actions">
373
+ <button class="action primary"
374
+ onClick=${() => {
375
+ // Clear displaced FIRST so the next render swaps the
376
+ // overlay out for the host div — that's the only
377
+ // way hostRef.current populates. Then bump the nonce
378
+ // so the effect re-runs with the freshly-mounted
379
+ // host and opens a new WS. Doing both in one tick
380
+ // batches the state updates; React renders, mounts
381
+ // the host, then flushes effects.
382
+ setDisplaced(false);
383
+ setReattach((n) => n + 1);
384
+ }}>
385
+ Take it back
386
+ </button>
387
+ </div>
388
+ <p class="terminal-displaced-hint">
389
+ Taking it back will close the other client the same way.
390
+ </p>
391
+ </div>
392
+ </section>`;
393
+ }
394
+ return html`<div key="host" ref=${hostRef} class="terminal-host"></div>`;
337
395
  }
@@ -124,6 +124,12 @@ export function RemotePage() {
124
124
  const [token, setTokenLocal] = useState('');
125
125
  const [busy, setBusy] = useState(false);
126
126
  const [deviceList, setDeviceList] = useState([]);
127
+ // First /api/tunnel/status round-trip is the slow one — even with
128
+ // 30s server-side cache + parallel probe, a cold call shells out to
129
+ // where.exe / --version / `devtunnel user show` and adds ~700ms.
130
+ // We hide the form behind a spinner during that window so the user
131
+ // doesn't see empty radios + placeholders that suddenly fill in.
132
+ const [loading, setLoading] = useState(true);
127
133
  const pollRef = useRef(null);
128
134
 
129
135
  async function refresh() {
@@ -143,6 +149,7 @@ export function RemotePage() {
143
149
  return cur || 'cloudflared';
144
150
  });
145
151
  } catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
152
+ finally { setLoading(false); }
146
153
  }
147
154
 
148
155
  useEffect(() => {
@@ -245,6 +252,17 @@ export function RemotePage() {
245
252
  const cf = status?.providers?.cloudflared;
246
253
  const dt = status?.providers?.devtunnel;
247
254
 
255
+ if (loading) {
256
+ return html`
257
+ <${PageTitleBar} title="Remote" />
258
+ <div class="settings-scroll">
259
+ <div class="remote-loading">
260
+ <div class="remote-loading-spinner" aria-hidden="true"></div>
261
+ <p>Probing tunnel providers…</p>
262
+ </div>
263
+ </div>`;
264
+ }
265
+
248
266
  return html`
249
267
  <${PageTitleBar} title="Remote" />
250
268
  <div class="settings-scroll">
package/server.js CHANGED
@@ -1035,9 +1035,9 @@ app.post('/api/shutdown', (_req, res) => {
1035
1035
  // the chosen tunnel CLI. URL appears asynchronously in the CLI's
1036
1036
  // stdout; lib/tunnel parses it. /status returns the latest snapshot
1037
1037
  // for the page to poll.
1038
- app.get('/api/tunnel/status', (_req, res) => {
1039
- res.json(tunnel.status());
1040
- });
1038
+ app.get('/api/tunnel/status', asyncH(async (_req, res) => {
1039
+ res.json(await tunnel.status());
1040
+ }));
1041
1041
  app.post('/api/tunnel/start', asyncH(async (req, res) => {
1042
1042
  const { provider, token } = req.body || {};
1043
1043
  if (!token || String(token).length < 8) {
@@ -1048,20 +1048,20 @@ app.post('/api/tunnel/start', asyncH(async (req, res) => {
1048
1048
  const result = await tunnel.start({ provider, port: currentPort });
1049
1049
  res.json(result);
1050
1050
  } catch (e) {
1051
- res.status(400).json({ error: e.message, providers: tunnel.probe() });
1051
+ res.status(400).json({ error: e.message, providers: await tunnel.probe().catch(() => ({})) });
1052
1052
  }
1053
1053
  }));
1054
- app.post('/api/tunnel/stop', (_req, res) => {
1054
+ app.post('/api/tunnel/stop', asyncH(async (_req, res) => {
1055
1055
  const stopped = tunnel.stop();
1056
- res.json({ stopped, ...tunnel.status() });
1057
- });
1058
- app.post('/api/tunnel/token', (req, res) => {
1056
+ res.json({ stopped, ...(await tunnel.status()) });
1057
+ }));
1058
+ app.post('/api/tunnel/token', asyncH(async (req, res) => {
1059
1059
  // Bare token update without touching the running tunnel.
1060
1060
  // POST { token: '' } to clear and disable remote auth.
1061
1061
  const t = (req.body && req.body.token) || '';
1062
1062
  tunnel.setToken(t);
1063
- res.json(tunnel.status());
1064
- });
1063
+ res.json(await tunnel.status());
1064
+ }));
1065
1065
  app.post('/api/tunnel/install', asyncH(async (req, res) => {
1066
1066
  const { provider } = req.body || {};
1067
1067
  try {