@bakapiano/ccsm 0.21.4 → 0.22.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/lib/config.js CHANGED
@@ -80,12 +80,24 @@ const DEFAULTS = {
80
80
  // Launch button when the user doesn't override.
81
81
  clis: DEFAULT_CLIS,
82
82
  defaultCliId: 'claude',
83
+ // External editor command for the "Open in editor" session action.
84
+ // Spawned as `<editor> "<cwd>"`; default `code` = VS Code (whose Source
85
+ // Control panel doubles as the review-changes view once the folder's
86
+ // open). Point it at `cursor`, `code-insiders`, `subl`, … as desired.
87
+ editor: 'code',
83
88
  // Devtunnel state. tunnelId holds the persistent (named) tunnel
84
89
  // ccsm minted via `devtunnel create` on first Start. Reusing it
85
90
  // across host restarts keeps the public URL — and therefore the
86
91
  // remote browsers' approval records — stable. `devtunnel delete <id>`
87
92
  // is invoked when the user explicitly rotates via the Reset button.
88
93
  devtunnel: { tunnelId: null },
94
+ // Provider-agnostic tunnel prefs. When autoStart is on, the backend
95
+ // brings the tunnel up during its own startup (server.js boot hook) —
96
+ // NOT an OS-level autostart. token is persisted so share URLs survive
97
+ // a backend restart; it's written ONLY while autoStart is on and is
98
+ // stripped from /api/config so remote devices can't read it. provider
99
+ // is 'devtunnel' | 'cloudflared'.
100
+ tunnel: { autoStart: false, provider: null, token: null },
89
101
  };
90
102
 
91
103
  function ensureDataDir() {
@@ -117,9 +129,10 @@ migrateLegacyDataIfNeeded();
117
129
  // object so callers don't mutate DEFAULTS.
118
130
  function mergeWithDefaults(partial) {
119
131
  const out = { ...DEFAULTS, ...partial };
120
- // Deep-merge devtunnel so a partial save (just .tunnelId) doesn't
121
- // wipe sibling keys we may add later.
132
+ // Deep-merge devtunnel + tunnel so a partial save (just .tunnelId, or
133
+ // just .autoStart) doesn't wipe sibling keys.
122
134
  out.devtunnel = { ...DEFAULTS.devtunnel, ...(partial?.devtunnel || {}) };
135
+ out.tunnel = { ...DEFAULTS.tunnel, ...(partial?.tunnel || {}) };
123
136
  // Drop v0.x keys that the new architecture doesn't use.
124
137
  delete out.terminal;
125
138
  delete out.commandShell;
@@ -135,6 +148,7 @@ function mergeWithDefaults(partial) {
135
148
 
136
149
  if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
137
150
  if (!Array.isArray(out.clis)) out.clis = [];
151
+ if (typeof out.editor !== 'string') out.editor = DEFAULTS.editor;
138
152
  // Always inject builtin CLIs (claude, codex) if they're missing or were
139
153
  // deleted from a saved config — they're managed by ccsm, the user can
140
154
  // tweak args/shell but can't remove them. Preserves any user
@@ -231,7 +245,19 @@ async function saveConfig(partial) {
231
245
  ensureDataDir();
232
246
  return withFileLock(CONFIG_PATH, async () => {
233
247
  const current = await loadConfig();
234
- const next = mergeWithDefaults({ ...current, ...partial });
248
+ // mergeWithDefaults re-merges nested objects (devtunnel, tunnel)
249
+ // against DEFAULTS only, so a partial save like
250
+ // saveConfig({ tunnel: { autoStart: true } }) would reset the
251
+ // sibling token/provider back to defaults. Pre-merge the nested
252
+ // blocks against `current` so a partial update preserves siblings.
253
+ const merged = { ...current, ...partial };
254
+ if (partial && partial.devtunnel) {
255
+ merged.devtunnel = { ...current.devtunnel, ...partial.devtunnel };
256
+ }
257
+ if (partial && partial.tunnel) {
258
+ merged.tunnel = { ...current.tunnel, ...partial.tunnel };
259
+ }
260
+ const next = mergeWithDefaults(merged);
235
261
  await atomicWriteJson(CONFIG_PATH, next);
236
262
  return next;
237
263
  });
package/lib/tunnel.js CHANGED
@@ -80,6 +80,12 @@ const PROVIDERS = {
80
80
  // In-memory state. Single tunnel at a time — switching providers tears
81
81
  // down the old one first.
82
82
  let current = null; // { provider, child, url, startedAt, log: string[] }
83
+ let starting = false; // True while start() is mid-spawn. devtunnel does
84
+ // ~10-20s of async create/configure BEFORE `current`
85
+ // is assigned, so the `if (current)` guard alone
86
+ // can't stop a second concurrent start() (boot
87
+ // auto-start racing a manual click) from spawning a
88
+ // duplicate child. This flag closes that window.
83
89
  let token = null; // Remote-access bearer token. Null = no remote
84
90
  // access enforced. Set via setToken() or by the
85
91
  // start() call. Server.js middleware reads via
@@ -319,6 +325,10 @@ function probeCachedSWR() {
319
325
  }
320
326
 
321
327
  async function status() {
328
+ // One config read serves both the persisted tunnelId and the
329
+ // auto-start prefs the Remote page's toggle reflects.
330
+ let cfg = null;
331
+ try { cfg = await loadConfig(); } catch { /* fall back to nulls below */ }
322
332
  return {
323
333
  providers: probeCachedSWR(),
324
334
  running: !!current,
@@ -328,10 +338,11 @@ async function status() {
328
338
  pid: current?.child?.pid || null,
329
339
  log: current?.log?.slice(-50) || [],
330
340
  token,
331
- tunnelId: current?.tunnelId || (await (async () => {
332
- try { return (await loadConfig())?.devtunnel?.tunnelId || null; }
333
- catch { return null; }
334
- })()),
341
+ tunnelId: current?.tunnelId || cfg?.devtunnel?.tunnelId || null,
342
+ // Persisted auto-start prefs surfaced so the toggle renders the
343
+ // right state across reloads (the page already polls /status).
344
+ autoStart: cfg?.tunnel?.autoStart ?? false,
345
+ autoStartProvider: cfg?.tunnel?.provider ?? null,
335
346
  // Token is echoed back so the Remote page can render the
336
347
  // pre-built share URL. The route itself is token-protected
337
348
  // (the middleware blocks non-loopback callers without it), so
@@ -487,36 +498,51 @@ function clearDevtunnelLogin() {
487
498
  // installed, the provider is unknown, or another tunnel is running.
488
499
  async function start({ provider, port }) {
489
500
  if (current) throw new Error('tunnel already running');
501
+ if (starting) throw new Error('tunnel is already starting');
490
502
  const p = PROVIDERS[provider];
491
503
  if (!p) throw new Error(`unknown provider: ${provider}`);
492
- const exe = await findBinary(provider);
493
- if (!exe) throw new Error(`${p.label} is not installed`);
494
- if (provider === 'devtunnel') {
495
- const { loggedIn } = await checkDevtunnelLogin(exe);
496
- if (!loggedIn) throw new Error('devtunnel requires login — run `devtunnel user login` first');
497
- }
498
504
 
499
- // Resolve devtunnel's persistent id BEFORE building args so the
500
- // CLI is invoked with `host <id>` and the public URL stays stable
501
- // across restarts. Cloudflared has no equivalent here quick
502
- // tunnels always rotate URLs and named tunnels require a CF
503
- // account + DNS setup that's outside ccsm's scope.
504
- let tunnelId = null;
505
- if (provider === 'devtunnel') {
506
- tunnelId = await ensureDevtunnelTunnelId(exe);
507
- if (tunnelId) {
508
- // Make sure the port is in the tunnel's port list and anonymous
509
- // access is enabled. `devtunnel host <id>` doesn't accept port /
510
- // access flags after the tunnel exists, so this has to be done
511
- // out of band before host.
512
- await configureDevtunnelTunnel(exe, tunnelId, port);
505
+ // Hold the `starting` flag across the async setup below. devtunnel's
506
+ // create/configure can take 10-20s, all of it BEFORE `current` is
507
+ // assigned without this flag a second start() (boot auto-start vs.
508
+ // a manual click) would slip past the `if (current)` guard and spawn
509
+ // a duplicate child. Cleared the moment `current` is set, after which
510
+ // the `if (current)` guard alone is sufficient.
511
+ starting = true;
512
+ let entry;
513
+ let child;
514
+ try {
515
+ const exe = await findBinary(provider);
516
+ if (!exe) throw new Error(`${p.label} is not installed`);
517
+ if (provider === 'devtunnel') {
518
+ const { loggedIn } = await checkDevtunnelLogin(exe);
519
+ if (!loggedIn) throw new Error('devtunnel requires login — run `devtunnel user login` first');
513
520
  }
514
- }
515
521
 
516
- const args = p.args(port, { tunnelId });
517
- const child = spawn(exe, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
518
- const entry = { provider, child, url: null, startedAt: Date.now(), log: [], tunnelId };
519
- current = entry;
522
+ // Resolve devtunnel's persistent id BEFORE building args so the
523
+ // CLI is invoked with `host <id>` and the public URL stays stable
524
+ // across restarts. Cloudflared has no equivalent here quick
525
+ // tunnels always rotate URLs and named tunnels require a CF
526
+ // account + DNS setup that's outside ccsm's scope.
527
+ let tunnelId = null;
528
+ if (provider === 'devtunnel') {
529
+ tunnelId = await ensureDevtunnelTunnelId(exe);
530
+ if (tunnelId) {
531
+ // Make sure the port is in the tunnel's port list and anonymous
532
+ // access is enabled. `devtunnel host <id>` doesn't accept port /
533
+ // access flags after the tunnel exists, so this has to be done
534
+ // out of band before host.
535
+ await configureDevtunnelTunnel(exe, tunnelId, port);
536
+ }
537
+ }
538
+
539
+ const args = p.args(port, { tunnelId });
540
+ child = spawn(exe, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
541
+ entry = { provider, child, url: null, startedAt: Date.now(), log: [], tunnelId };
542
+ current = entry;
543
+ } finally {
544
+ starting = false;
545
+ }
520
546
 
521
547
  const pushLog = (line) => {
522
548
  entry.log.push(line);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.21.4",
3
+ "version": "0.22.0",
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",
@@ -349,12 +349,17 @@
349
349
  display: inline-flex;
350
350
  align-items: center;
351
351
  justify-content: center;
352
- color: #fff;
352
+ /* Follow the terminal foreground so the dots read on both the light
353
+ (#f0f0f0) and dark (#252526) tab strip. The old hardcoded #fff was
354
+ invisible on the light strip. */
355
+ color: var(--term-on);
353
356
  cursor: pointer;
354
357
  flex-shrink: 0;
355
358
  transition: background-color .12s, color .12s;
356
359
  }
357
- .session-menu-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
360
+ /* Neutral-grey hover tint works on either strip colour (darkens the light
361
+ one, lightens the dark one) without needing a per-theme override. */
362
+ .session-menu-btn:hover { background: rgba(128, 128, 128, 0.2); color: var(--term-on); }
358
363
  .session-menu-btn svg { width: 16px; height: 16px; }
359
364
 
360
365
  .session-menu {
@@ -1838,6 +1838,32 @@
1838
1838
  Running: a status banner (state · provider · uptime · stop link)
1839
1839
  followed by a copyable share URL block and an optional collapsed
1840
1840
  CLI log. */
1841
+ .tunnel-autostart {
1842
+ display: flex;
1843
+ flex-direction: column;
1844
+ gap: 6px;
1845
+ margin-bottom: var(--s-4);
1846
+ }
1847
+ .tunnel-autostart-row {
1848
+ display: flex;
1849
+ align-items: center;
1850
+ gap: 9px;
1851
+ cursor: pointer;
1852
+ font-size: 13px;
1853
+ color: var(--ink-mid);
1854
+ }
1855
+ .tunnel-autostart-row input {
1856
+ flex: 0 0 auto;
1857
+ cursor: pointer;
1858
+ }
1859
+ .tunnel-autostart-label {
1860
+ user-select: none;
1861
+ }
1862
+ .tunnel-autostart-hint {
1863
+ /* indent under the checkbox so it lines up with the label text */
1864
+ padding-left: 26px;
1865
+ }
1866
+
1841
1867
  .tunnel-hero {
1842
1868
  display: flex;
1843
1869
  align-items: center;
package/public/js/api.js CHANGED
@@ -259,6 +259,13 @@ export async function deleteSession(sessionId) {
259
259
  await loadSessions();
260
260
  }
261
261
 
262
+ // Open the session's working directory in the user's configured editor
263
+ // (Settings → Editor, default `code`). Returns { editor, cwd } so the
264
+ // caller can surface which editor it launched.
265
+ export function openSessionInEditor(sessionId) {
266
+ return api('POST', `/api/sessions/${sessionId}/open-editor`);
267
+ }
268
+
262
269
  // Per-session in-flight resume promise. Sidebar.onClick and the
263
270
  // SessionsPage auto-resume effect can both fire for the same exited
264
271
  // session in the same tick (clicking an exited row mounts SessionsPage
@@ -154,7 +154,7 @@ export function ConfigurePage() {
154
154
 
155
155
  useEffect(() => {
156
156
  if (cfg && !general) {
157
- setGeneral({ workDir: cfg.workDir });
157
+ setGeneral({ workDir: cfg.workDir, editor: cfg.editor });
158
158
  }
159
159
  }, [cfg]);
160
160
 
@@ -167,6 +167,7 @@ export function ConfigurePage() {
167
167
  const saved = await api('PUT', '/api/config', {
168
168
  ...cfg,
169
169
  workDir: (merged.workDir || '').trim(),
170
+ editor: (merged.editor || '').trim(),
170
171
  });
171
172
  config.value = saved;
172
173
  setToast('saved');
@@ -198,6 +199,13 @@ export function ConfigurePage() {
198
199
  <span class="label">Backend</span>
199
200
  <${RestartButton} />
200
201
  </div>
202
+ <label class="field">
203
+ <span class="label">Editor</span>
204
+ <input type="text" class="mono" value=${general.editor || ''}
205
+ placeholder="code"
206
+ onChange=${(e) => saveGeneral({ editor: e.target.value })} />
207
+ <span class="hint">Command for a session's “Open in editor” action. Default <code>code</code> (VS Code). Try <code>cursor</code>, <code>code-insiders</code>, …</span>
208
+ </label>
201
209
  </div>
202
210
  </${Section}>
203
211
 
@@ -38,16 +38,29 @@ function saveLaunchState(s) {
38
38
  try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {}
39
39
  }
40
40
 
41
- function initRepoSelection(repos, override) {
42
- if (override && Array.isArray(override)) {
43
- // Only honour names that still exist in the user's repo list;
44
- // anything else was deleted between sessions.
45
- const valid = new Set(repos.map((r) => r.name));
46
- selectedRepos.value = new Set(override.filter((n) => valid.has(n)));
47
- return;
41
+ function initRepoSelection(repos, saved) {
42
+ const valid = new Set(repos.map((r) => r.name));
43
+ const sel = new Set();
44
+ // Start from the persisted selection (last-used picks), keeping only
45
+ // repos that still exist.
46
+ if (saved && Array.isArray(saved.repos)) {
47
+ for (const n of saved.repos) if (valid.has(n)) sel.add(n);
48
48
  }
49
- const want = new Set(repos.filter((r) => r.defaultSelected).map((r) => r.name));
50
- selectedRepos.value = want;
49
+ // Auto-check any repo whose "pre-select on launch" default is newly
50
+ // active — i.e. it wasn't a default the last time we saved. This
51
+ // covers both a brand-new default repo and an existing repo the user
52
+ // just flipped to default in Configure. A default the user previously
53
+ // unchecked stays unchecked (it's an old default, already in
54
+ // knownDefaults). With no saved knownDefaults (fresh user / old
55
+ // state), every default applies.
56
+ const knownDefaults = saved && Array.isArray(saved.knownDefaults)
57
+ ? new Set(saved.knownDefaults) : null;
58
+ for (const r of repos) {
59
+ if (r.defaultSelected && (knownDefaults === null || !knownDefaults.has(r.name))) {
60
+ sel.add(r.name);
61
+ }
62
+ }
63
+ selectedRepos.value = sel;
51
64
  }
52
65
 
53
66
  function LaunchHero() {
@@ -86,17 +99,21 @@ function LaunchHero() {
86
99
  }, [folderId, folders.value.length]);
87
100
 
88
101
  // Persist every change. JSON-stringifying a Set isn't useful, so
89
- // we materialize selectedRepos to an array here.
102
+ // we materialize selectedRepos to an array here. knownDefaults records
103
+ // which repos were marked "pre-select" at save time so
104
+ // initRepoSelection can tell a newly-flipped default apart from one the
105
+ // user deliberately unchecked.
90
106
  useEffect(() => {
91
107
  saveLaunchState({
92
108
  cliId, folderId, mode, cwd,
93
109
  repos: [...selectedRepos.value],
110
+ knownDefaults: repos.filter((r) => r.defaultSelected).map((r) => r.name),
94
111
  });
95
112
  }, [cliId, folderId, mode, cwd, selectedRepos.value]);
96
113
 
97
114
 
98
115
  const sig = repos.map((r) => r.name + ':' + r.defaultSelected).join('|');
99
- useStateOnce(sig, () => initRepoSelection(repos, saved?.repos));
116
+ useStateOnce(sig, () => initRepoSelection(repos, saved));
100
117
 
101
118
  const cli = clis.find((c) => c.id === cliId) || clis[0];
102
119
  const folder = folders.value.find((f) => f.id === folderId);
@@ -423,11 +423,35 @@ export function RemotePage() {
423
423
  const fresh = genToken();
424
424
  setTokenLocal(fresh);
425
425
  try {
426
- const s = await api('POST', '/api/tunnel/token', { token: fresh });
426
+ // When auto-start is on the token must be PERSISTED, else the
427
+ // rotated token is lost on the next backend restart and every
428
+ // share URL built from it 401s. Route through the persisting
429
+ // endpoint in that case; otherwise the in-memory-only token
430
+ // endpoint is enough.
431
+ const s = status?.autoStart
432
+ ? await api('POST', '/api/tunnel/autostart', { autoStart: true, provider, token: fresh })
433
+ : await api('POST', '/api/tunnel/token', { token: fresh });
427
434
  setStatus(s);
428
435
  setToast('New token in effect', 'ok');
429
436
  } catch (e) { setToast(`token save failed · ${e.message}`, 'error'); }
430
437
  }
438
+ // Persist (or clear) the auto-start preference. On enable with no
439
+ // token yet, mint one first so the backend has something to reuse on
440
+ // its next startup. Approved devices keep working regardless of the
441
+ // token — it only gates NEW device registration.
442
+ async function onToggleAutoStart(next) {
443
+ setBusy(true);
444
+ try {
445
+ let tok = token;
446
+ if (next && (!tok || tok.length < 8)) { tok = genToken(); setTokenLocal(tok); }
447
+ const s = await api('POST', '/api/tunnel/autostart',
448
+ next ? { autoStart: true, provider, token: tok } : { autoStart: false });
449
+ setStatus(s);
450
+ setToast(next ? 'Auto-start on · tunnel comes up when ccsm starts' : 'Auto-start off', 'ok');
451
+ } catch (e) {
452
+ setToast(`auto-start ${next ? 'enable' : 'disable'} failed · ${e.message}`, 'error');
453
+ } finally { setBusy(false); }
454
+ }
431
455
  async function onInstall(p) {
432
456
  const ok = await ccsmConfirm(`Install ${p} via winget? Runs in the background — refresh after ~30s.`, {
433
457
  title: 'Install tunnel provider', okLabel: 'Install',
@@ -553,6 +577,18 @@ export function RemotePage() {
553
577
  meta=${running
554
578
  ? html`Provider <code>${status?.provider}</code> · started ${new Date(status.startedAt).toLocaleTimeString()}`
555
579
  : html`Not running.`}>
580
+ <div class="tunnel-autostart">
581
+ <label class="tunnel-autostart-row">
582
+ <input type="checkbox" checked=${!!status?.autoStart} disabled=${busy}
583
+ onChange=${(e) => onToggleAutoStart(e.target.checked)} />
584
+ <span class="tunnel-autostart-label">Start this tunnel automatically when ccsm starts</span>
585
+ </label>
586
+ ${status?.autoStart && provider === 'cloudflared' ? html`
587
+ <span class="hint tunnel-autostart-hint">
588
+ Cloudflare quick tunnels get a new URL each launch — the share URL will change on
589
+ restart and approved devices must re-register. Use Microsoft Dev Tunnel for a stable URL.
590
+ </span>` : null}
591
+ </div>
556
592
  ${!running ? html`
557
593
  <div class="tunnel-hero">
558
594
  <div class="tunnel-hero-body">
@@ -7,13 +7,13 @@
7
7
  import { html } from '../html.js';
8
8
  import { useEffect, useRef, useState } from 'preact/hooks';
9
9
  import { activeSessionId, sessions, config, selectTab, selectSession, clockTick } from '../state.js';
10
- import { resumeSession, clearResumeFailure, deleteSession, setSessionTitle } from '../api.js';
10
+ import { resumeSession, clearResumeFailure, deleteSession, setSessionTitle, openSessionInEditor } from '../api.js';
11
11
  import { setToast } from '../toast.js';
12
12
  import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
13
13
  import { TerminalView } from '../components/TerminalView.js';
14
14
  import { PageTitleBar } from '../components/PageTitleBar.js';
15
15
  import { Popover } from '../components/Popover.js';
16
- import { IconMoreVert, IconPencil, IconClose, IconPlus, IconForCliType, IconTerminal } from '../icons.js';
16
+ import { IconMoreVert, IconPencil, IconClose, IconPlus, IconForCliType, IconTerminal, IconExternal } from '../icons.js';
17
17
  import { fmtAgo } from '../util.js';
18
18
 
19
19
  function SessionTabs({ activeId, onActivate, onNew, kebab }) {
@@ -51,7 +51,7 @@ function SessionTabs({ activeId, onActivate, onNew, kebab }) {
51
51
  </div>`;
52
52
  }
53
53
 
54
- function SessionMenu({ session, onRename, onDelete }) {
54
+ function SessionMenu({ session, onRename, onDelete, onOpenEditor }) {
55
55
  const [open, setOpen] = useState(false);
56
56
  const anchor = useRef(null);
57
57
  return html`
@@ -61,9 +61,12 @@ function SessionMenu({ session, onRename, onDelete }) {
61
61
  <${IconMoreVert} />
62
62
  </button>
63
63
  ${open ? html`
64
- <${Popover} anchor=${anchor} align="right" width=${180}
64
+ <${Popover} anchor=${anchor} align="right" width=${200}
65
65
  onClose=${() => setOpen(false)}>
66
66
  <div class="session-menu">
67
+ <button class="session-menu-item" onClick=${() => { setOpen(false); onOpenEditor(); }}>
68
+ <${IconExternal} /> Open in editor
69
+ </button>
67
70
  <button class="session-menu-item" onClick=${() => { setOpen(false); onRename(); }}>
68
71
  <${IconPencil} /> Rename
69
72
  </button>
@@ -129,6 +132,12 @@ export function SessionsPage() {
129
132
  activeSessionId.value = null;
130
133
  } catch (e) { setToast(e.message, 'error'); }
131
134
  };
135
+ const onOpenEditor = async () => {
136
+ try {
137
+ const r = await openSessionInEditor(session.id);
138
+ setToast(`Opening in ${r?.editor || 'editor'}…`);
139
+ } catch (e) { setToast(e.message, 'error'); }
140
+ };
132
141
 
133
142
  return html`
134
143
  <${PageTitleBar} title=${html`
@@ -146,7 +155,7 @@ export function SessionsPage() {
146
155
  activeId=${session.id}
147
156
  onActivate=${(sid) => selectSession(sid)}
148
157
  onNew=${() => selectTab('launch')}
149
- kebab=${html`<${SessionMenu} session=${session} onRename=${onRename} onDelete=${onDelete} />`} />
158
+ kebab=${html`<${SessionMenu} session=${session} onRename=${onRename} onDelete=${onDelete} onOpenEditor=${onOpenEditor} />`} />
150
159
  <div class="session-pane">
151
160
  <div class="session-pane-body">
152
161
  ${running
package/server.js CHANGED
@@ -456,13 +456,27 @@ function decorateConfigWithProbes(cfg) {
456
456
  };
457
457
  }
458
458
 
459
+ // The tunnel + devtunnel config blocks are managed exclusively through
460
+ // /api/tunnel/* (host-only) — they hold the persisted remote-access
461
+ // token and the named tunnelId. Strip them from /api/config so (a) the
462
+ // plaintext token never reaches an approved remote device reading config
463
+ // and (b) the frontend's whole-object config round-trip on save can't
464
+ // clobber tunnelId/token with a stale snapshot.
465
+ function stripTunnelKeys(cfg) {
466
+ const rest = { ...cfg };
467
+ delete rest.tunnel;
468
+ delete rest.devtunnel;
469
+ return rest;
470
+ }
459
471
  app.get('/api/config', asyncH(async (_req, res) => {
460
- res.json(decorateConfigWithProbes(await loadConfig()));
472
+ res.json(decorateConfigWithProbes(stripTunnelKeys(await loadConfig())));
461
473
  }));
462
474
 
463
475
  app.put('/api/config', asyncH(async (req, res) => {
464
- const cfg = await saveConfig(req.body || {});
465
- res.json(decorateConfigWithProbes(cfg));
476
+ const body = { ...(req.body || {}) };
477
+ delete body.tunnel;
478
+ delete body.devtunnel;
479
+ res.json(decorateConfigWithProbes(stripTunnelKeys(await saveConfig(body))));
466
480
  }));
467
481
 
468
482
  // ---- CLI probe / test ----
@@ -633,6 +647,35 @@ app.delete('/api/sessions/:id', asyncH(async (req, res) => {
633
647
  res.json({ removed });
634
648
  }));
635
649
 
650
+ // Open a session's working directory in the user's configured editor
651
+ // (config.editor, default `code` = VS Code, whose Source Control panel is
652
+ // also the review-changes view once the folder's open). Spawned detached
653
+ // so it outlives ccsm; shell:true so Windows resolves `code.cmd` via
654
+ // PATHEXT and a command like `code --reuse-window` parses, with the cwd
655
+ // quoted so paths with spaces survive the shell. spawnEnv() merges the
656
+ // user-scope PATH so `code`/`cursor` are found even when the inherited
657
+ // env lacks them.
658
+ app.post('/api/sessions/:id/open-editor', asyncH(async (req, res) => {
659
+ const record = await persistedSessions.get(req.params.id);
660
+ if (!record) return res.status(404).json({ error: 'session not found' });
661
+ const cfg = await loadConfig();
662
+ const editor = (cfg.editor || '').trim() || 'code';
663
+ const { spawn } = require('node:child_process');
664
+ try {
665
+ const child = spawn(editor, [`"${record.cwd}"`], {
666
+ detached: true, stdio: 'ignore', shell: true,
667
+ env: spawnEnv(), windowsHide: true,
668
+ });
669
+ // A bad editor command fails the shell async (after we've responded);
670
+ // log it so it's diagnosable, but the happy path needs no await.
671
+ child.on('error', (e) => console.warn(`[ccsm] open-editor "${editor}" failed:`, e.message));
672
+ child.unref();
673
+ res.json({ ok: true, editor, cwd: record.cwd });
674
+ } catch (e) {
675
+ res.status(500).json({ error: `failed to launch ${editor}: ${e.message}` });
676
+ }
677
+ }));
678
+
636
679
  // Reorder sessions within a folder. Body: { folderId, ids } where ids
637
680
  // is the new sequence of session ids in their final display order
638
681
  // inside that folder. Each session gets `folderId` + `order: 0..N-1`
@@ -1133,6 +1176,28 @@ app.post('/api/tunnel/token', asyncH(async (req, res) => {
1133
1176
  tunnel.setToken(t);
1134
1177
  res.json(await tunnel.status());
1135
1178
  }));
1179
+ // Persist auto-start prefs. When ON, the backend brings this tunnel up
1180
+ // during its own startup (the boot hook in the listen IIFE below) using
1181
+ // the persisted token, so share URLs survive a backend restart. The
1182
+ // token is written to config ONLY while auto-start is on; turning it off
1183
+ // wipes the persisted token from disk. setToken keeps the in-memory copy
1184
+ // in lockstep so the share URL the page renders stays valid immediately.
1185
+ app.post('/api/tunnel/autostart', asyncH(async (req, res) => {
1186
+ const { autoStart, provider, token } = req.body || {};
1187
+ if (autoStart) {
1188
+ if (!token || String(token).length < 8) {
1189
+ return res.status(400).json({ error: 'token required (≥ 8 chars)' });
1190
+ }
1191
+ if (!['devtunnel', 'cloudflared'].includes(provider)) {
1192
+ return res.status(400).json({ error: 'valid provider required' });
1193
+ }
1194
+ tunnel.setToken(token);
1195
+ await saveConfig({ tunnel: { autoStart: true, provider, token } });
1196
+ } else {
1197
+ await saveConfig({ tunnel: { autoStart: false, provider: null, token: null } });
1198
+ }
1199
+ res.json(await tunnel.status());
1200
+ }));
1136
1201
  app.post('/api/tunnel/install', asyncH(async (req, res) => {
1137
1202
  const { provider } = req.body || {};
1138
1203
  try {
@@ -1646,6 +1711,20 @@ function openInBrowser(url) {
1646
1711
  // is warm by the time anyone clicks.
1647
1712
  try { tunnel.probe(true).catch(() => {}); } catch {}
1648
1713
 
1714
+ // Auto-start the tunnel if the user enabled it on the Remote page.
1715
+ // This is the BACKEND PROCESS bringing its own tunnel up on startup —
1716
+ // not an OS-level autostart (no registry / scheduled task). Reuses the
1717
+ // persisted token so share URLs stay valid across restarts. Strictly
1718
+ // fire-and-forget: a failure here (devtunnel not signed in, provider
1719
+ // uninstalled, etc.) must never crash boot — it just logs and the user
1720
+ // can start manually from the Remote page.
1721
+ if (cfg.tunnel?.autoStart && cfg.tunnel?.token && cfg.tunnel?.provider) {
1722
+ tunnel.setToken(cfg.tunnel.token);
1723
+ tunnel.start({ provider: cfg.tunnel.provider, port: currentPort })
1724
+ .then((s) => console.log(`[ccsm] tunnel auto-started · ${cfg.tunnel.provider} · ${s.url || 'URL pending'}`))
1725
+ .catch((e) => console.warn(`[ccsm] tunnel auto-start failed · ${e.message}`));
1726
+ }
1727
+
1649
1728
  if (webTerminal.available) {
1650
1729
  let WebSocketServer;
1651
1730
  try { ({ WebSocketServer } = require('ws')); } catch {}