@bakapiano/ccsm 0.19.3 → 0.19.4

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/config.js CHANGED
@@ -75,6 +75,12 @@ const DEFAULTS = {
75
75
  // Launch button when the user doesn't override.
76
76
  clis: DEFAULT_CLIS,
77
77
  defaultCliId: 'claude',
78
+ // Devtunnel state. tunnelId holds the persistent (named) tunnel
79
+ // ccsm minted via `devtunnel create` on first Start. Reusing it
80
+ // across host restarts keeps the public URL — and therefore the
81
+ // remote browsers' approval records — stable. `devtunnel delete <id>`
82
+ // is invoked when the user explicitly rotates via the Reset button.
83
+ devtunnel: { tunnelId: null },
78
84
  };
79
85
 
80
86
  function ensureDataDir() {
@@ -106,6 +112,9 @@ migrateLegacyDataIfNeeded();
106
112
  // object so callers don't mutate DEFAULTS.
107
113
  function mergeWithDefaults(partial) {
108
114
  const out = { ...DEFAULTS, ...partial };
115
+ // Deep-merge devtunnel so a partial save (just .tunnelId) doesn't
116
+ // wipe sibling keys we may add later.
117
+ out.devtunnel = { ...DEFAULTS.devtunnel, ...(partial?.devtunnel || {}) };
109
118
  // Drop v0.x keys that the new architecture doesn't use.
110
119
  delete out.terminal;
111
120
  delete out.commandShell;
package/lib/tunnel.js CHANGED
@@ -22,6 +22,7 @@ const path = require('node:path');
22
22
  const fs = require('node:fs');
23
23
  const os = require('node:os');
24
24
  const { promisify } = require('node:util');
25
+ const { loadConfig, saveConfig } = require('./config');
25
26
  const execFileP = promisify(execFile);
26
27
 
27
28
  const PROVIDERS = {
@@ -46,7 +47,21 @@ const PROVIDERS = {
46
47
  path.join(process.env['LOCALAPPDATA'] || '', 'Microsoft', 'WinGet', 'Packages',
47
48
  'Microsoft.devtunnel_Microsoft.Winget.Source_8wekyb3d8bbwe', 'devtunnel.exe'),
48
49
  ],
49
- args: (port) => ['host', '-p', String(port), '--allow-anonymous'],
50
+ args: (port, opts = {}) => {
51
+ // With a persistent (named) tunnel, ports + anonymous access are
52
+ // configured ahead of time via `devtunnel port create` and
53
+ // `devtunnel access create` (see ensureDevtunnelTunnelId +
54
+ // configureDevtunnelTunnel). The host call then takes ONLY the
55
+ // tunnel id — passing -p or --allow-anonymous here makes the CLI
56
+ // try to batch-update the existing tunnel and the service rejects
57
+ // it ("Batch update of ports is not supported"). Anonymous +
58
+ // ephemeral mode keeps the legacy flags as a fallback when no
59
+ // tunnel id has been minted.
60
+ if (opts.tunnelId) {
61
+ return ['host', opts.tunnelId];
62
+ }
63
+ return ['host', '-p', String(port), '--allow-anonymous'];
64
+ },
50
65
  // devtunnel sometimes prints two URL forms for the same tunnel:
51
66
  // https://<id>.<region>.devtunnels.ms:<port> ← port as suffix
52
67
  // https://<id>-<port>.<region>.devtunnels.ms ← port baked into
@@ -128,6 +143,115 @@ async function checkDevtunnelLogin(exe) {
128
143
  }
129
144
  }
130
145
 
146
+ // Mint (or read back) a persistent devtunnel id and stash it in
147
+ // config.devtunnel.tunnelId so subsequent `devtunnel host` invocations
148
+ // reuse the same public URL.
149
+ //
150
+ // Why: every `devtunnel host` without a tunnel id allocates a fresh
151
+ // random id, which means a fresh subdomain and therefore a fresh
152
+ // browser origin on the remote side. localStorage is per-origin, so
153
+ // approved device ids get orphaned and the remote user has to re-
154
+ // register from scratch on every tunnel restart.
155
+ //
156
+ // Behaviour:
157
+ // - If config already has a tunnelId, return it verbatim. We do NOT
158
+ // validate it against `devtunnel list` here — the host child will
159
+ // fail loudly if the id was deleted from another machine, and
160
+ // callers can drop it and retry by deleting config.devtunnel.
161
+ // - Otherwise call `devtunnel create --json`, capture the .tunnelId
162
+ // from the response, persist it, return it.
163
+ // - If the create call fails for any reason, return null and let
164
+ // start() fall back to a temporary tunnel — degraded but working.
165
+ async function ensureDevtunnelTunnelId(exe) {
166
+ try {
167
+ const cfg = await loadConfig();
168
+ if (cfg?.devtunnel?.tunnelId) return cfg.devtunnel.tunnelId;
169
+ const { stdout } = await execFileP(exe, ['create', '--json'], {
170
+ windowsHide: true,
171
+ timeout: 20_000,
172
+ });
173
+ let id = null;
174
+ try {
175
+ const j = JSON.parse(String(stdout));
176
+ // The CLI's JSON shape varies a bit by version; the canonical
177
+ // field is `tunnelId` but older builds nest it under `tunnel`.
178
+ id = j.tunnelId || j.tunnel?.tunnelId || j.id || null;
179
+ } catch {
180
+ // Fall back to scraping the plain-text output for `Tunnel ID: foo`
181
+ const m = String(stdout).match(/Tunnel ID:\s*(\S+)/i);
182
+ if (m) id = m[1];
183
+ }
184
+ if (!id) return null;
185
+ // Persist for next time. Swallow save errors — worst case the next
186
+ // start re-allocates one.
187
+ try { await saveConfig({ devtunnel: { tunnelId: id } }); }
188
+ catch (e) { console.warn('[tunnel] persist devtunnel id failed:', e.message); }
189
+ return id;
190
+ } catch (e) {
191
+ console.warn('[tunnel] ensureDevtunnelTunnelId failed:', e.message);
192
+ return null;
193
+ }
194
+ }
195
+
196
+ // Bring a named tunnel into shape for hosting: make sure the port is
197
+ // in its port list and anonymous access is allowed. Idempotent — the
198
+ // "already exists" errors from `port create` / `access create` are
199
+ // silently absorbed. Required because `devtunnel host <id>` (which
200
+ // we use for persistent tunnels) doesn't accept -p / --allow-anonymous
201
+ // flags after the tunnel exists — passing them triggers the service
202
+ // to reject the call with "Batch update of ports is not supported".
203
+ async function configureDevtunnelTunnel(exe, tunnelId, port) {
204
+ if (!tunnelId) return;
205
+ // Add port. If it already exists the CLI prints an error; swallow it.
206
+ try {
207
+ await execFileP(exe, ['port', 'create', tunnelId, '-p', String(port)], {
208
+ windowsHide: true,
209
+ timeout: 10_000,
210
+ });
211
+ } catch (e) {
212
+ // Only surface failures that aren't "already exists" — those mean
213
+ // something is genuinely broken (auth lost, wrong tunnel id, etc.).
214
+ const msg = String(e.stderr || e.stdout || e.message || '');
215
+ if (!/already/i.test(msg)) {
216
+ console.warn('[tunnel] devtunnel port create failed:', msg.slice(0, 200));
217
+ }
218
+ }
219
+ // Add anonymous access. Same idempotency story.
220
+ try {
221
+ await execFileP(exe, ['access', 'create', tunnelId, '-a'], {
222
+ windowsHide: true,
223
+ timeout: 10_000,
224
+ });
225
+ } catch (e) {
226
+ const msg = String(e.stderr || e.stdout || e.message || '');
227
+ if (!/already/i.test(msg)) {
228
+ console.warn('[tunnel] devtunnel access create failed:', msg.slice(0, 200));
229
+ }
230
+ }
231
+ }
232
+
233
+ // Delete the persisted devtunnel id (and the remote tunnel resource
234
+ // itself, best-effort). Used by the Reset affordance in the Remote
235
+ // page when the user wants to rotate the public URL.
236
+ async function resetDevtunnelTunnelId() {
237
+ let prevId = null;
238
+ try {
239
+ const cfg = await loadConfig();
240
+ prevId = cfg?.devtunnel?.tunnelId || null;
241
+ } catch {}
242
+ try { await saveConfig({ devtunnel: { tunnelId: null } }); } catch {}
243
+ if (prevId) {
244
+ const exe = await findBinary('devtunnel');
245
+ if (exe) {
246
+ // Detach from the tunnel resource on the Azure side too; failure
247
+ // is fine (resource may already be gone). 5s cap.
248
+ try { await execFileP(exe, ['delete', prevId, '-f'], { windowsHide: true, timeout: 5_000 }); }
249
+ catch { /* ignore */ }
250
+ }
251
+ }
252
+ return { previousTunnelId: prevId };
253
+ }
254
+
131
255
  // Probe is cached. Each cold call shells out 4-6 sync execs (where.exe,
132
256
  // --version per provider, `devtunnel user show`) — cumulatively ~1s of
133
257
  // blocked event loop on Windows. The Remote page polls /api/tunnel/status
@@ -178,6 +302,10 @@ async function status() {
178
302
  pid: current?.child?.pid || null,
179
303
  log: current?.log?.slice(-50) || [],
180
304
  token,
305
+ tunnelId: current?.tunnelId || (await (async () => {
306
+ try { return (await loadConfig())?.devtunnel?.tunnelId || null; }
307
+ catch { return null; }
308
+ })()),
181
309
  // Token is echoed back so the Remote page can render the
182
310
  // pre-built share URL. The route itself is token-protected
183
311
  // (the middleware blocks non-loopback callers without it), so
@@ -342,9 +470,26 @@ async function start({ provider, port }) {
342
470
  if (!loggedIn) throw new Error('devtunnel requires login — run `devtunnel user login` first');
343
471
  }
344
472
 
345
- const args = p.args(port);
473
+ // Resolve devtunnel's persistent id BEFORE building args so the
474
+ // CLI is invoked with `host <id>` and the public URL stays stable
475
+ // across restarts. Cloudflared has no equivalent here — quick
476
+ // tunnels always rotate URLs and named tunnels require a CF
477
+ // account + DNS setup that's outside ccsm's scope.
478
+ let tunnelId = null;
479
+ if (provider === 'devtunnel') {
480
+ tunnelId = await ensureDevtunnelTunnelId(exe);
481
+ if (tunnelId) {
482
+ // Make sure the port is in the tunnel's port list and anonymous
483
+ // access is enabled. `devtunnel host <id>` doesn't accept port /
484
+ // access flags after the tunnel exists, so this has to be done
485
+ // out of band before host.
486
+ await configureDevtunnelTunnel(exe, tunnelId, port);
487
+ }
488
+ }
489
+
490
+ const args = p.args(port, { tunnelId });
346
491
  const child = spawn(exe, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
347
- const entry = { provider, child, url: null, startedAt: Date.now(), log: [] };
492
+ const entry = { provider, child, url: null, startedAt: Date.now(), log: [], tunnelId };
348
493
  current = entry;
349
494
 
350
495
  const pushLog = (line) => {
@@ -420,4 +565,5 @@ module.exports = {
420
565
  cancelDevtunnelLogin,
421
566
  clearDevtunnelLogin,
422
567
  invalidateProbe,
568
+ resetDevtunnelTunnelId,
423
569
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.19.3",
3
+ "version": "0.19.4",
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",
@@ -1774,6 +1774,48 @@
1774
1774
  .provider-status-switch { margin-left: auto; }
1775
1775
  .provider-status-signin { margin-left: auto; }
1776
1776
 
1777
+ /* DevtunnelTunnelIdRow — shown under the signed-in Microsoft Dev
1778
+ Tunnel status. The tunnel id is the persistent identifier that
1779
+ keeps the public URL stable across `devtunnel host` restarts;
1780
+ surfaced so the user knows what's being reused and can rotate it
1781
+ when they want fresh credentials. */
1782
+ .tunnel-id-row {
1783
+ display: flex;
1784
+ align-items: center;
1785
+ flex-wrap: wrap;
1786
+ gap: var(--s-2);
1787
+ margin-top: var(--s-2);
1788
+ padding: 6px 10px;
1789
+ border: 1px solid var(--border-soft);
1790
+ border-radius: 6px;
1791
+ background: var(--bg);
1792
+ font-size: 12px;
1793
+ color: var(--ink-mid);
1794
+ }
1795
+ .tunnel-id-row.is-empty { color: var(--ink-muted); }
1796
+ .tunnel-id-label {
1797
+ font-size: 10.5px;
1798
+ letter-spacing: 0.16em;
1799
+ text-transform: uppercase;
1800
+ font-weight: 600;
1801
+ color: var(--ink-muted);
1802
+ }
1803
+ .tunnel-id-value {
1804
+ font-family: var(--mono);
1805
+ font-size: 12px;
1806
+ color: var(--ink);
1807
+ user-select: all;
1808
+ padding: 1px 6px;
1809
+ border-radius: 4px;
1810
+ background: var(--bg-elev);
1811
+ border: 1px solid var(--border-soft);
1812
+ }
1813
+ .tunnel-id-value-empty {
1814
+ font-style: italic;
1815
+ color: var(--ink-muted);
1816
+ }
1817
+ .tunnel-id-reset { margin-left: auto; }
1818
+
1777
1819
  /* ── Tunnel section: hero (idle) + live banner (running) ──────────
1778
1820
  Idle: a single horizontal card with the headline + start CTA.
1779
1821
  Running: a status banner (state · provider · uptime · stop link)
@@ -137,8 +137,26 @@ export function TerminalView({ terminalId, cliType }) {
137
137
 
138
138
  const host = hostRef.current;
139
139
  term.open(host);
140
- // Defer fit one tick so the container has measured layout
141
- requestAnimationFrame(() => { try { fit.fit(); } catch {} });
140
+ // Robust fit scheduler. A single requestAnimationFrame works most
141
+ // of the time but races on tab/session switches: the .tab-panel
142
+ // just flipped from display:none to display:flex and although the
143
+ // browser has laid the element out by the next frame, xterm's
144
+ // canvas measurement occasionally still reports the pre-display
145
+ // size (Chromium quirk — the WebGL renderer caches its viewport
146
+ // before the layout flush propagates through ResizeObserver).
147
+ // Result: visible "wrong cols/rows until I resize the window" bug.
148
+ // Spraying fits at 0 / one rAF / 60ms / 200ms covers every
149
+ // measurement-arrival path without being expensive — fit.fit() is
150
+ // a no-op when cols/rows match the previous call.
151
+ const scheduleFit = () => {
152
+ try { fit.fit(); } catch {}
153
+ requestAnimationFrame(() => {
154
+ try { fit.fit(); } catch {}
155
+ setTimeout(() => { try { fit.fit(); } catch {} }, 60);
156
+ setTimeout(() => { try { fit.fit(); } catch {} }, 200);
157
+ });
158
+ };
159
+ scheduleFit();
142
160
  termRef.current = term;
143
161
 
144
162
  // Browser WS API can't set Authorization headers — token + device
@@ -163,7 +181,7 @@ export function TerminalView({ terminalId, cliType }) {
163
181
  // wrapped at 80 cols, and the follow-up resize from the rAF fit
164
182
  // wouldn't reflow the already-emitted bytes. Visible as squeezed
165
183
  // text on every session switch.
166
- try { fit.fit(); } catch {}
184
+ scheduleFit();
167
185
  ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
168
186
  };
169
187
  ws.onmessage = (ev) => {
@@ -202,6 +220,20 @@ export function TerminalView({ terminalId, cliType }) {
202
220
  const ro = new ResizeObserver(() => { try { fit.fit(); } catch {} });
203
221
  ro.observe(hostRef.current);
204
222
 
223
+ // Mobile soft-keyboard resize. When the IME slides up on iOS /
224
+ // Android, the layout viewport doesn't change but `visualViewport`
225
+ // does — the page now has less vertical room before the keyboard
226
+ // covers the bottom. xterm's host element keeps its old layout
227
+ // height (we use 100vh-derived sizing) so half the terminal sits
228
+ // behind the keyboard with no resize callback fired. Listening
229
+ // here covers it: any visualViewport size change triggers a fit
230
+ // so the cell grid matches the visible area. Cheap; fit.fit() is
231
+ // a no-op when nothing changed.
232
+ const vv = window.visualViewport;
233
+ const onVisualResize = () => scheduleFit();
234
+ vv?.addEventListener?.('resize', onVisualResize);
235
+ vv?.addEventListener?.('scroll', onVisualResize);
236
+
205
237
  // Tab-switch refresh. The terminal lives inside a .tab-panel which gets
206
238
  // display:none when another tab is active. WebGL renderers keep a glyph
207
239
  // texture atlas in GPU memory; when the canvas hides + redisplays at a
@@ -217,7 +249,7 @@ export function TerminalView({ terminalId, cliType }) {
217
249
  if (panel.hasAttribute('data-active')) {
218
250
  requestAnimationFrame(() => {
219
251
  try { term.clearTextureAtlas?.(); } catch {}
220
- try { fit.fit(); } catch {}
252
+ scheduleFit();
221
253
  try { term.refresh(0, term.rows - 1); } catch {}
222
254
  });
223
255
  }
@@ -377,6 +409,8 @@ export function TerminalView({ terminalId, cliType }) {
377
409
  }
378
410
  ro.disconnect();
379
411
  if (panelMo) panelMo.disconnect();
412
+ vv?.removeEventListener?.('resize', onVisualResize);
413
+ vv?.removeEventListener?.('scroll', onVisualResize);
380
414
  try { ws.close(); } catch {}
381
415
  try { term.dispose(); } catch {}
382
416
  termRef.current = null;
@@ -21,7 +21,32 @@ import { BrandMark, IconTerminal, IconFolder, IconFolderOpen, IconBranch, IconCh
21
21
  const ROOT_ID = 'newSessionProgress';
22
22
  const selectedRepos = signal(new Set());
23
23
 
24
- function initRepoSelection(repos) {
24
+ // Persist the user's last Launch picks (CLI / folder / mode / cwd /
25
+ // selected repos) so the form stays as they left it across reloads
26
+ // and tab switches. localStorage is best-effort — any access failure
27
+ // falls back silently.
28
+ const LS_KEY = 'ccsm.launch-state';
29
+ function loadLaunchState() {
30
+ try {
31
+ const raw = localStorage.getItem(LS_KEY);
32
+ if (!raw) return null;
33
+ const j = JSON.parse(raw);
34
+ if (j && typeof j === 'object') return j;
35
+ } catch {}
36
+ return null;
37
+ }
38
+ function saveLaunchState(s) {
39
+ try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {}
40
+ }
41
+
42
+ function initRepoSelection(repos, override) {
43
+ if (override && Array.isArray(override)) {
44
+ // Only honour names that still exist in the user's repo list;
45
+ // anything else was deleted between sessions.
46
+ const valid = new Set(repos.map((r) => r.name));
47
+ selectedRepos.value = new Set(override.filter((n) => valid.has(n)));
48
+ return;
49
+ }
25
50
  const want = new Set(repos.filter((r) => r.defaultSelected).map((r) => r.name));
26
51
  selectedRepos.value = want;
27
52
  }
@@ -31,11 +56,15 @@ function LaunchHero() {
31
56
  const clis = cfg.clis || [];
32
57
  const repos = cfg.repos || [];
33
58
  const defaultCli = cfg.defaultCliId || clis[0]?.id || '';
59
+ const saved = loadLaunchState();
34
60
 
35
- const [cliId, setCliId] = useState(defaultCli);
36
- const [folderId, setFolderId] = useState('');
37
- const [mode, setMode] = useState('auto'); // 'auto' = workspace + repos, 'cwd' = pick existing dir
38
- const [cwd, setCwd] = useState(''); // only used when mode === 'cwd'
61
+ // Initial values pull from localStorage first (last-used picks),
62
+ // then fall back to config defaults. cliId is validated below in
63
+ // the useEffect once `clis` arrives.
64
+ const [cliId, setCliId] = useState(saved?.cliId || defaultCli);
65
+ const [folderId, setFolderId] = useState(saved?.folderId || '');
66
+ const [mode, setMode] = useState(saved?.mode === 'cwd' ? 'cwd' : 'auto');
67
+ const [cwd, setCwd] = useState(saved?.cwd || ''); // only used when mode === 'cwd'
39
68
  const [busy, setBusy] = useState(false);
40
69
  const [result, setResult] = useState('');
41
70
  const [openPicker, setOpenPicker] = useState(null); // 'cli' | 'folder' | 'workdir' | null
@@ -50,6 +79,22 @@ function LaunchHero() {
50
79
  }
51
80
  }, [defaultCli, clis.length]);
52
81
 
82
+ // Validate the persisted folder id against the live folders list
83
+ // — folders deleted between sessions snap back to "no folder".
84
+ useEffect(() => {
85
+ if (!folderId) return;
86
+ if (!folders.value.find((f) => f.id === folderId)) setFolderId('');
87
+ }, [folderId, folders.value.length]);
88
+
89
+ // Persist every change. JSON-stringifying a Set isn't useful, so
90
+ // we materialize selectedRepos to an array here.
91
+ useEffect(() => {
92
+ saveLaunchState({
93
+ cliId, folderId, mode, cwd,
94
+ repos: [...selectedRepos.value],
95
+ });
96
+ }, [cliId, folderId, mode, cwd, selectedRepos.value]);
97
+
53
98
  const folderDnd = useDragSort(
54
99
  folders.value.map((f) => f.id),
55
100
  async (nextIds) => {
@@ -59,7 +104,7 @@ function LaunchHero() {
59
104
  );
60
105
 
61
106
  const sig = repos.map((r) => r.name + ':' + r.defaultSelected).join('|');
62
- useStateOnce(sig, () => initRepoSelection(repos));
107
+ useStateOnce(sig, () => initRepoSelection(repos, saved?.repos));
63
108
 
64
109
  const cli = clis.find((c) => c.id === cliId) || clis[0];
65
110
  const folder = folders.value.find((f) => f.id === folderId);
@@ -104,6 +104,32 @@ function ProviderTile({ id, label, hint, icon, selected, disabled, onSelect }) {
104
104
  </button>`;
105
105
  }
106
106
 
107
+ // Tiny inline row shown under the signed-in Microsoft Dev Tunnel
108
+ // status. Displays the persisted (named) tunnel id ccsm reuses across
109
+ // restarts so the public URL stays stable — and lets the user rotate
110
+ // it on demand. Reset requires the tunnel to be stopped first; the
111
+ // server-side route also enforces this.
112
+ function DevtunnelTunnelIdRow({ tunnelId, running, onReset }) {
113
+ if (!tunnelId) {
114
+ return html`
115
+ <div class="tunnel-id-row is-empty">
116
+ <span class="tunnel-id-label">Tunnel id</span>
117
+ <span class="tunnel-id-value-empty">none yet · minted on next Start</span>
118
+ </div>`;
119
+ }
120
+ return html`
121
+ <div class="tunnel-id-row">
122
+ <span class="tunnel-id-label">Tunnel id</span>
123
+ <code class="tunnel-id-value" title="Stable public URL identifier · reused across restarts">${tunnelId}</code>
124
+ <button type="button" class="action subtle small tunnel-id-reset"
125
+ disabled=${running}
126
+ title=${running ? 'Stop the tunnel first' : 'Mint a fresh tunnel id (public URL will change)'}
127
+ onClick=${onReset}>
128
+ <${IconRecycle} /> Reset
129
+ </button>
130
+ </div>`;
131
+ }
132
+
107
133
  function ProviderStatus({ id, info, onInstall, onLogin, loggingIn }) {
108
134
  if (!info) return html`<span class="provider-status-muted">probing…</span>`;
109
135
  if (!info.installed) {
@@ -261,9 +287,26 @@ function DevtunnelLoginPanel({ login, onCancel, onDismiss, onRetry }) {
261
287
 
262
288
  export function RemotePage() {
263
289
  clockTick.value; // re-tick fmtAgo "last seen" labels
264
- const [status, setStatus] = useState(null);
265
- const [provider, setProvider] = useState('cloudflared');
266
- const [token, setTokenLocal] = useState('');
290
+ // Hydrate from a localStorage cache so the page renders the same
291
+ // shape it had at the end of the previous visit — provider tiles,
292
+ // signed-in state, tunnel id, share URL — instead of empty / placeholder
293
+ // chrome that fills in after the slow /api/tunnel/status round-trip
294
+ // (700ms+ on a cold probe). The cached snapshot is overwritten by
295
+ // refresh() the moment the live response lands.
296
+ const cachedStatus = (() => {
297
+ try {
298
+ const raw = localStorage.getItem('ccsm.remote-status-cache');
299
+ return raw ? JSON.parse(raw) : null;
300
+ } catch { return null; }
301
+ })();
302
+ const [status, setStatus] = useState(cachedStatus);
303
+ const [provider, setProvider] = useState(() => {
304
+ if (cachedStatus?.running && cachedStatus?.provider) return cachedStatus.provider;
305
+ if (cachedStatus?.providers?.devtunnel?.installed) return 'devtunnel';
306
+ if (cachedStatus?.providers?.cloudflared?.installed) return 'cloudflared';
307
+ return 'devtunnel';
308
+ });
309
+ const [token, setTokenLocal] = useState(cachedStatus?.token || '');
267
310
  const [busy, setBusy] = useState(false);
268
311
  const [deviceList, setDeviceList] = useState([]);
269
312
  const pollRef = useRef(null);
@@ -280,10 +323,17 @@ export function RemotePage() {
280
323
  setProvider((cur) => {
281
324
  if (s.running && s.provider) return s.provider;
282
325
  if (cur) return cur;
283
- if (s.providers?.cloudflared?.installed) return 'cloudflared';
284
326
  if (s.providers?.devtunnel?.installed) return 'devtunnel';
285
- return cur || 'cloudflared';
327
+ if (s.providers?.cloudflared?.installed) return 'cloudflared';
328
+ return cur || 'devtunnel';
286
329
  });
330
+ // Snapshot for the next mount. Skip the per-call `log` so the
331
+ // cache stays small.
332
+ try {
333
+ localStorage.setItem('ccsm.remote-status-cache', JSON.stringify({
334
+ ...s, log: undefined,
335
+ }));
336
+ } catch {}
287
337
  } catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
288
338
  }
289
339
 
@@ -400,6 +450,18 @@ export function RemotePage() {
400
450
  try { await api('POST', '/api/tunnel/devtunnel/login/dismiss'); refresh(); }
401
451
  catch (e) { setToast(`dismiss failed · ${e.message}`, 'error'); }
402
452
  }
453
+ async function onResetDevtunnelId() {
454
+ const ok = await ccsmConfirm(
455
+ `Mint a fresh tunnel id? The public URL changes — every approved remote device will need to re-register on the new URL. Any existing share links stop working.`,
456
+ { title: 'Reset Microsoft Dev Tunnel id', okLabel: 'Reset', danger: true },
457
+ );
458
+ if (!ok) return;
459
+ try {
460
+ await api('POST', '/api/tunnel/devtunnel/reset');
461
+ refresh();
462
+ setToast('Tunnel id reset · next Start mints a fresh one', 'ok');
463
+ } catch (e) { setToast(`reset failed · ${e.message}`, 'error'); }
464
+ }
403
465
 
404
466
  const running = status?.running;
405
467
  const url = status?.url;
@@ -426,30 +488,21 @@ export function RemotePage() {
426
488
  <div class="field">
427
489
  <span class="label">Provider</span>
428
490
  <div class="provider-tile-row">
429
- <${ProviderTile} id="cloudflared" label="Cloudflare Tunnel"
430
- hint="Anonymous · no login"
431
- icon=${html`<${IconCloudflareColor} size=${32} />`}
432
- selected=${provider === 'cloudflared'}
433
- disabled=${running}
434
- onSelect=${setProvider} />
435
491
  <${ProviderTile} id="devtunnel" label="Microsoft Dev Tunnel"
436
492
  hint="Requires sign-in"
437
493
  icon=${html`<${IconMicrosoftColor} size=${32} />`}
438
494
  selected=${provider === 'devtunnel'}
439
495
  disabled=${running}
440
496
  onSelect=${setProvider} />
497
+ <${ProviderTile} id="cloudflared" label="Cloudflare Tunnel"
498
+ hint="Anonymous · no login"
499
+ icon=${html`<${IconCloudflareColor} size=${32} />`}
500
+ selected=${provider === 'cloudflared'}
501
+ disabled=${running}
502
+ onSelect=${setProvider} />
441
503
  </div>
442
504
  ${running ? html`<span class="hint">Stop the tunnel to switch provider.</span>` : null}
443
505
  </div>
444
- ${provider === 'cloudflared' ? html`
445
- <div class="field">
446
- <span class="label">Cloudflare Tunnel</span>
447
- <div class="remote-status-line">
448
- <${ProviderStatus} id="cloudflared" info=${cf}
449
- onInstall=${() => onInstall('cloudflared')} />
450
- </div>
451
- </div>
452
- ` : null}
453
506
  ${provider === 'devtunnel' ? html`
454
507
  <div class="field">
455
508
  <span class="label">Microsoft Dev Tunnel</span>
@@ -466,6 +519,21 @@ export function RemotePage() {
466
519
  onDismiss=${onLoginDismiss}
467
520
  onRetry=${() => onLogin('devtunnel')} />
468
521
  ` : null}
522
+ ${dt?.loggedIn ? html`
523
+ <${DevtunnelTunnelIdRow}
524
+ tunnelId=${status?.tunnelId}
525
+ running=${running && status?.provider === 'devtunnel'}
526
+ onReset=${onResetDevtunnelId} />
527
+ ` : null}
528
+ </div>
529
+ ` : null}
530
+ ${provider === 'cloudflared' ? html`
531
+ <div class="field">
532
+ <span class="label">Cloudflare Tunnel</span>
533
+ <div class="remote-status-line">
534
+ <${ProviderStatus} id="cloudflared" info=${cf}
535
+ onInstall=${() => onInstall('cloudflared')} />
536
+ </div>
469
537
  </div>
470
538
  ` : null}
471
539
  </div>
package/server.js CHANGED
@@ -1092,6 +1092,20 @@ app.post('/api/tunnel/devtunnel/login/dismiss', asyncH(async (_req, res) => {
1092
1092
  tunnel.clearDevtunnelLogin();
1093
1093
  res.json({ ok: true });
1094
1094
  }));
1095
+ // Wipe the persisted devtunnel tunnel id (and the remote tunnel
1096
+ // resource itself, best-effort) so the next /api/tunnel/start mints
1097
+ // a fresh one. Used by the Reset button in the Remote page when the
1098
+ // user wants to rotate the public URL. Tunnel must be stopped first
1099
+ // — refuse otherwise so we don't yank state out from under a live
1100
+ // `devtunnel host` child.
1101
+ app.post('/api/tunnel/devtunnel/reset', asyncH(async (_req, res) => {
1102
+ const s = await tunnel.status();
1103
+ if (s.running && s.provider === 'devtunnel') {
1104
+ return res.status(409).json({ error: 'stop the tunnel before resetting its id' });
1105
+ }
1106
+ const r = await tunnel.resetDevtunnelTunnelId();
1107
+ res.json({ ok: true, ...r, ...(await tunnel.status()) });
1108
+ }));
1095
1109
 
1096
1110
  // ---- devices ----
1097
1111
  //
@@ -1555,6 +1569,12 @@ function openInBrowser(url) {
1555
1569
  // a slow Import dialog cold-open. Fire in the background; the lib also
1556
1570
  // starts its own 15s refresh loop.
1557
1571
  try { localCliSessions.prewarmLivePids(['claude.exe']); } catch {}
1572
+ // Prewarm tunnel provider probe. First /api/tunnel/status round-trip
1573
+ // shells out to where.exe / --version / devtunnel user show — ~700ms
1574
+ // of synchronous work that the user otherwise waits on the moment
1575
+ // they open the Remote tab. Fire in the background here so the cache
1576
+ // is warm by the time anyone clicks.
1577
+ try { tunnel.probe(true).catch(() => {}); } catch {}
1558
1578
 
1559
1579
  if (webTerminal.available) {
1560
1580
  let WebSocketServer;