@bakapiano/ccsm 0.18.1 → 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,14 +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
- const out = execFileSync('where.exe', [p.binary], { stdio: ['ignore', 'pipe', 'ignore'] })
71
- .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];
72
75
  if (out && fs.existsSync(out)) return out;
73
76
  } catch { /* not on PATH */ }
74
77
  // Fall back to known install locations (winget's PATH update doesn't
@@ -92,20 +95,18 @@ function findBinary(provider) {
92
95
  return null;
93
96
  }
94
97
 
95
- function getVersion(provider, exe) {
98
+ async function getVersion(provider, exe) {
96
99
  try {
97
- const out = execFileSync(exe, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
98
- .toString().trim().split(/\r?\n/)[0];
99
- return out || null;
100
+ const { stdout } = await execFileP(exe, ['--version'], { windowsHide: true });
101
+ return String(stdout).trim().split(/\r?\n/)[0] || null;
100
102
  } catch { return null; }
101
103
  }
102
104
 
103
- function checkDevtunnelLogin(exe) {
105
+ async function checkDevtunnelLogin(exe) {
104
106
  try {
105
- const out = execFileSync(exe, ['user', 'show'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 })
106
- .toString().trim();
107
+ const { stdout } = await execFileP(exe, ['user', 'show'], { windowsHide: true, timeout: 5000 });
107
108
  // "Logged in as <email> using <provider>." vs "Not logged in"
108
- const m = out.match(/Logged in as (\S+)/);
109
+ const m = String(stdout).trim().match(/Logged in as (\S+)/);
109
110
  if (m) return { loggedIn: true, user: m[1] };
110
111
  return { loggedIn: false, user: null };
111
112
  } catch {
@@ -123,31 +124,39 @@ const PROBE_TTL_MS = 30_000;
123
124
  let probeCache = null;
124
125
  let probeCacheAt = 0;
125
126
 
126
- function probe(force = false) {
127
+ async function probe(force = false) {
127
128
  if (!force && probeCache && Date.now() - probeCacheAt < PROBE_TTL_MS) {
128
129
  return probeCache;
129
130
  }
130
- const out = {};
131
- for (const id of Object.keys(PROVIDERS)) {
132
- const exe = findBinary(id);
133
- const p = { installed: !!exe, exe, version: exe ? getVersion(id, exe) : null };
134
- if (id === 'devtunnel' && exe) {
135
- 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);
136
145
  }
137
- out[id] = p;
138
- }
139
- probeCache = out;
146
+ return [id, p];
147
+ }));
148
+ probeCache = Object.fromEntries(results);
140
149
  probeCacheAt = Date.now();
141
- return out;
150
+ return probeCache;
142
151
  }
143
152
 
144
153
  // Invalidate the cache when callers know the on-disk state likely changed
145
154
  // (post-install, post-login, etc.). Next probe() re-shells.
146
155
  function invalidateProbe() { probeCache = null; probeCacheAt = 0; }
147
156
 
148
- function status() {
157
+ async function status() {
149
158
  return {
150
- providers: probe(),
159
+ providers: await probe(),
151
160
  running: !!current,
152
161
  provider: current?.provider || null,
153
162
  url: current?.url || null,
@@ -169,10 +178,10 @@ async function start({ provider, port }) {
169
178
  if (current) throw new Error('tunnel already running');
170
179
  const p = PROVIDERS[provider];
171
180
  if (!p) throw new Error(`unknown provider: ${provider}`);
172
- const exe = findBinary(provider);
181
+ const exe = await findBinary(provider);
173
182
  if (!exe) throw new Error(`${p.label} is not installed`);
174
183
  if (provider === 'devtunnel') {
175
- const { loggedIn } = checkDevtunnelLogin(exe);
184
+ const { loggedIn } = await checkDevtunnelLogin(exe);
176
185
  if (!loggedIn) throw new Error('devtunnel requires login — run `devtunnel user login` first');
177
186
  }
178
187
 
@@ -206,7 +215,7 @@ async function start({ provider, port }) {
206
215
  // Wait up to 25s for the URL to show up in stdout.
207
216
  const deadline = Date.now() + 25_000;
208
217
  while (Date.now() < deadline) {
209
- if (entry.url) return status();
218
+ if (entry.url) return await status();
210
219
  if (!current || current !== entry) {
211
220
  throw new Error('tunnel exited before reporting a URL · ' + entry.log.slice(-3).join(' / '));
212
221
  }
@@ -214,7 +223,7 @@ async function start({ provider, port }) {
214
223
  }
215
224
  // Timed out — keep the child alive (the URL might appear later) but
216
225
  // tell the caller we don't have one yet.
217
- return status();
226
+ return await status();
218
227
  }
219
228
 
220
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.1",
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 {