@bakapiano/ccsm 0.18.6 → 0.19.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/devices.js CHANGED
@@ -89,7 +89,7 @@ async function pruneStale(map) {
89
89
 
90
90
  // Upsert. Returns the (possibly newly-created) device record. Caller
91
91
  // uses .status to decide whether to gate further work.
92
- async function record(id, { userAgent, ip } = {}) {
92
+ async function record(id, { userAgent, ip, code } = {}) {
93
93
  if (!id) throw new Error('device id required');
94
94
  return withFileLock(store.filePath, async () => {
95
95
  const map = await store.load();
@@ -100,12 +100,20 @@ async function record(id, { userAgent, ip } = {}) {
100
100
  // is lastSeen and we flushed for this id within MIN_FLUSH_MS,
101
101
  // return the in-memory copy without touching disk. Saves a
102
102
  // disk write per request when remote pages poll at 2.5s.
103
- const onlyLastSeen = (!userAgent || existing.userAgent) && (!ip || existing.ip === ip);
104
103
  const recentlyFlushed = (now - (lastFlushAt.get(id) || 0)) < MIN_FLUSH_MS;
104
+ let nonLastSeenChange = false;
105
105
  existing.lastSeen = now;
106
- if (userAgent && !existing.userAgent) existing.userAgent = userAgent;
107
- if (ip) existing.ip = ip;
108
- if (onlyLastSeen && recentlyFlushed) return existing;
106
+ if (userAgent && !existing.userAgent) { existing.userAgent = userAgent; nonLastSeenChange = true; }
107
+ if (ip && existing.ip !== ip) { existing.ip = ip; nonLastSeenChange = true; }
108
+ // Backfill code on existing records that pre-date the field, but
109
+ // never overwrite a code we've already stored — a device's code
110
+ // is its identifier across the approval flow and changing it
111
+ // mid-request would defeat the disambiguation it exists for.
112
+ if (code && !existing.code) {
113
+ existing.code = String(code).slice(0, 8);
114
+ nonLastSeenChange = true;
115
+ }
116
+ if (!nonLastSeenChange && recentlyFlushed) return existing;
109
117
  await store.save(map);
110
118
  lastFlushAt.set(id, now);
111
119
  return existing;
@@ -115,6 +123,12 @@ async function record(id, { userAgent, ip } = {}) {
115
123
  status: 'pending',
116
124
  userAgent: userAgent || null,
117
125
  ip: ip || null,
126
+ // 4-digit identification code the requesting browser generated
127
+ // and stashed in its own localStorage. Purely human-facing;
128
+ // the Remote page renders it next to each pending device so the
129
+ // operator can match against what the user reads off their
130
+ // screen before clicking Approve. Not a credential.
131
+ code: code ? String(code).slice(0, 8) : null,
118
132
  firstSeen: now,
119
133
  lastSeen: now,
120
134
  approvedAt: null,
package/lib/tunnel.js CHANGED
@@ -69,6 +69,10 @@ let token = null; // Remote-access bearer token. Null = no remote
69
69
  // access enforced. Set via setToken() or by the
70
70
  // start() call. Server.js middleware reads via
71
71
  // getToken().
72
+ let login = null; // Pending interactive `devtunnel user login -d`
73
+ // flow · { child, mode, lines, url, code, status,
74
+ // startedAt, finishedAt, error, user }. Single
75
+ // flow at a time. See startDevtunnelLogin().
72
76
 
73
77
  function getToken() { return token; }
74
78
  function setToken(t) { token = t ? String(t) : null; return token; }
@@ -178,9 +182,152 @@ async function status() {
178
182
  // pre-built share URL. The route itself is token-protected
179
183
  // (the middleware blocks non-loopback callers without it), so
180
184
  // anyone reaching this endpoint already knows the token.
185
+ login: loginSnapshot(),
181
186
  };
182
187
  }
183
188
 
189
+ // ---- devtunnel interactive login (device-code flow) ----
190
+ //
191
+ // `devtunnel user login -d` prints a Microsoft device-code line then
192
+ // polls Azure until the user authenticates in a browser (or until it
193
+ // times out). We spawn it as a child, parse the URL + code out of the
194
+ // first informational lines, and expose progress via /api/tunnel/status
195
+ // so the Remote page can render a one-click sign-in panel instead of
196
+ // asking the user to paste a command into a terminal.
197
+ //
198
+ // Two modes: 'microsoft' (default, -d) and 'github' (-g -d). GitHub is
199
+ // only worth offering if the user explicitly picks it — Microsoft
200
+ // device code works for Entra ID / personal MS accounts and is what
201
+ // most people land on.
202
+ //
203
+ // State machine:
204
+ // running → child alive, waiting for user
205
+ // done → child exited 0; probe cache is invalidated so the next
206
+ // providers map shows `loggedIn: true`
207
+ // error → child exited non-zero or crashed
208
+ // canceled → cancelDevtunnelLogin() killed the child
209
+ function loginSnapshot() {
210
+ if (!login) return null;
211
+ return {
212
+ mode: login.mode,
213
+ status: login.status,
214
+ url: login.url,
215
+ code: login.code,
216
+ error: login.error || null,
217
+ user: login.user || null,
218
+ startedAt: login.startedAt,
219
+ finishedAt: login.finishedAt || null,
220
+ lines: login.lines.slice(-40),
221
+ };
222
+ }
223
+
224
+ async function startDevtunnelLogin({ mode = 'microsoft' } = {}) {
225
+ if (login && login.status === 'running') {
226
+ // Already in flight · return the snapshot rather than throwing so
227
+ // a double-click on Sign in doesn't error out.
228
+ return loginSnapshot();
229
+ }
230
+ const exe = await findBinary('devtunnel');
231
+ if (!exe) throw new Error('Microsoft Dev Tunnel is not installed');
232
+ // Starting a fresh login drops any existing credentials from disk
233
+ // before the new flow finishes — so the cached probe ("signed in as
234
+ // old-user") is now lying. Invalidate so the next /status round-
235
+ // trip re-shells `devtunnel user show` and the provider line flips
236
+ // to "not signed in" while the device-code panel is up.
237
+ invalidateProbe();
238
+
239
+ // -d picks the Microsoft device-code flow; -g switches it to GitHub.
240
+ // We deliberately stay on device code (no `--use-browser`) — the user
241
+ // may be driving the Remote page from a phone where opening a local
242
+ // browser on the host machine doesn't help.
243
+ const args = mode === 'github' ? ['user', 'login', '-g', '-d'] : ['user', 'login', '-d'];
244
+ const child = spawn(exe, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
245
+ const entry = {
246
+ child,
247
+ mode,
248
+ lines: [],
249
+ url: null,
250
+ code: null,
251
+ status: 'running',
252
+ error: null,
253
+ user: null,
254
+ startedAt: Date.now(),
255
+ finishedAt: null,
256
+ };
257
+ login = entry;
258
+
259
+ const URL_RE = /https?:\/\/\S+/i;
260
+ // Microsoft device-code prompt examples (have varied over CLI
261
+ // versions): "...enter the code ABCD-1234 to authenticate"
262
+ // "...code XXXXXXXXX..."
263
+ // GitHub device flow uses 8 chars with a dash, e.g. `XXXX-XXXX`.
264
+ const CODE_RE = /\b([A-Z0-9]{4,}-?[A-Z0-9]{3,})\b/;
265
+ const LOGGED = /Logged in as (\S+)/i;
266
+
267
+ const ingest = (line) => {
268
+ if (!line) return;
269
+ entry.lines.push(line);
270
+ if (entry.lines.length > 100) entry.lines.shift();
271
+ if (!entry.url) {
272
+ const m = line.match(URL_RE);
273
+ if (m) entry.url = m[0].replace(/[.,)]+$/, '');
274
+ }
275
+ if (!entry.code && /code/i.test(line)) {
276
+ // Skip URL-bearing fragments before extracting the code so we
277
+ // don't grab a uuid-ish segment out of the device login URL.
278
+ const sans = line.replace(URL_RE, '');
279
+ const m = sans.match(CODE_RE);
280
+ if (m) entry.code = m[1];
281
+ }
282
+ const u = line.match(LOGGED);
283
+ if (u) entry.user = u[1];
284
+ };
285
+
286
+ child.stdout.setEncoding('utf8');
287
+ child.stderr.setEncoding('utf8');
288
+ child.stdout.on('data', (c) => c.split(/\r?\n/).forEach(ingest));
289
+ child.stderr.on('data', (c) => c.split(/\r?\n/).forEach(ingest));
290
+
291
+ child.on('exit', (code, signal) => {
292
+ entry.finishedAt = Date.now();
293
+ if (entry.status === 'canceled') {
294
+ // already terminal; leave as-is
295
+ } else if (code === 0) {
296
+ entry.status = 'done';
297
+ // The just-completed login means the probe cache is now lying
298
+ // about `loggedIn: false`. Drop it so the next status() call
299
+ // re-shells `devtunnel user show` and the UI flips to signed-in.
300
+ invalidateProbe();
301
+ } else {
302
+ entry.status = 'error';
303
+ entry.error = `devtunnel exited code=${code}${signal ? ` signal=${signal}` : ''}`;
304
+ }
305
+ });
306
+ child.on('error', (err) => {
307
+ entry.status = 'error';
308
+ entry.error = String(err && err.message || err);
309
+ entry.finishedAt = Date.now();
310
+ });
311
+
312
+ return loginSnapshot();
313
+ }
314
+
315
+ function cancelDevtunnelLogin() {
316
+ if (!login || login.status !== 'running') return loginSnapshot();
317
+ login.status = 'canceled';
318
+ login.finishedAt = Date.now();
319
+ try { login.child.kill(); } catch {}
320
+ return loginSnapshot();
321
+ }
322
+
323
+ function clearDevtunnelLogin() {
324
+ if (login && login.status === 'running') {
325
+ try { login.child.kill(); } catch {}
326
+ }
327
+ login = null;
328
+ return null;
329
+ }
330
+
184
331
  // Spawn the tunnel CLI. Resolves once we've parsed the public URL out
185
332
  // of stdout (with a timeout safety net). Throws if the CLI isn't
186
333
  // installed, the provider is unknown, or another tunnel is running.
@@ -269,4 +416,8 @@ module.exports = {
269
416
  installViaWinget,
270
417
  getToken,
271
418
  setToken,
419
+ startDevtunnelLogin,
420
+ cancelDevtunnelLogin,
421
+ clearDevtunnelLogin,
422
+ invalidateProbe,
272
423
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.18.6",
3
+ "version": "0.19.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",
@@ -167,6 +167,78 @@
167
167
  font-size: 14px;
168
168
  font-weight: 500;
169
169
  }
170
+ /* 4-digit identification code block on the pending-approval overlay.
171
+ The code matches what the host operator sees in the Remote page
172
+ alongside this pending request — visual confirmation they're
173
+ approving the right device. */
174
+ .offline-code-block {
175
+ display: flex;
176
+ flex-direction: column;
177
+ align-items: center;
178
+ gap: 6px;
179
+ margin-top: var(--s-3);
180
+ padding: var(--s-3) var(--s-5);
181
+ background: var(--bg);
182
+ border: 1px solid var(--border);
183
+ border-radius: 8px;
184
+ }
185
+ .offline-code-label {
186
+ font-size: 10.5px;
187
+ letter-spacing: 0.18em;
188
+ text-transform: uppercase;
189
+ color: var(--ink-muted);
190
+ }
191
+ .offline-code {
192
+ font-family: var(--mono);
193
+ font-size: 32px;
194
+ font-weight: 600;
195
+ letter-spacing: 0.18em;
196
+ color: var(--ink);
197
+ font-variant-numeric: tabular-nums;
198
+ }
199
+ .offline-code-hint {
200
+ font-size: 11.5px;
201
+ color: var(--ink-muted);
202
+ text-align: center;
203
+ }
204
+
205
+ /* RestartOverlay — small top-of-viewport pill (not fullscreen) while
206
+ a user-initiated backend restart is in flight. Slides down from the
207
+ top, slides out on dismiss. Pinned center so it survives PWA WCO /
208
+ standalone window-control overlays without bumping into them. */
209
+ .restart-banner {
210
+ position: fixed;
211
+ top: env(titlebar-area-height, 16px);
212
+ left: 50%;
213
+ transform: translateX(-50%);
214
+ z-index: 1200;
215
+ display: inline-flex;
216
+ align-items: center;
217
+ gap: 10px;
218
+ padding: 8px 16px;
219
+ border-radius: 999px;
220
+ background: var(--ink);
221
+ color: #fff;
222
+ font-size: 12.5px;
223
+ font-weight: 500;
224
+ letter-spacing: -0.005em;
225
+ box-shadow: 0 4px 18px rgba(0, 0, 0, 0.18);
226
+ animation: restart-banner-in .18s ease-out;
227
+ }
228
+ @keyframes restart-banner-in {
229
+ from { opacity: 0; transform: translate(-50%, -8px); }
230
+ to { opacity: 1; transform: translate(-50%, 0); }
231
+ }
232
+ .restart-banner-spinner {
233
+ width: 12px;
234
+ height: 12px;
235
+ border-radius: 50%;
236
+ border: 2px solid rgba(255, 255, 255, 0.25);
237
+ border-top-color: #fff;
238
+ animation: restart-spin 0.7s linear infinite;
239
+ }
240
+ .restart-banner-text { white-space: nowrap; }
241
+ @keyframes restart-spin { to { transform: rotate(360deg); } }
170
242
  .offline-fallback {
171
243
  margin-top: var(--s-3);
172
244
  width: 100%;
@@ -120,6 +120,14 @@
120
120
  pointers; touch never matches :hover. */
121
121
  .mobile-nav-fab:hover { background: var(--bg); }
122
122
  .mobile-nav-fab:active { cursor: grabbing; }
123
+ /* Suppress the browser's default focus ring + tap highlight — on touch
124
+ the ring lingers after every tap and reads as a stuck "selected"
125
+ state on the FAB. We're not removing all affordance: the icon swap
126
+ (hamburger ↔ X) + the is-open border change still communicate state.
127
+ :focus-visible is also dropped here since the FAB is touch-first. */
128
+ .mobile-nav-fab:focus,
129
+ .mobile-nav-fab:focus-visible { outline: none; }
130
+ .mobile-nav-fab { -webkit-tap-highlight-color: transparent; }
123
131
  .mobile-nav-fab svg { width: 22px; height: 22px; stroke-width: 2; pointer-events: none; }
124
132
  /* Open state stays the same paper white — only the icon swaps to X.
125
133
  Keeping the colour consistent reads as "same button, different
@@ -127,7 +135,7 @@
127
135
  .mobile-nav-fab.is-open {
128
136
  background: var(--bg-elev);
129
137
  color: var(--ink);
130
- border-color: var(--ink);
138
+ border-color: var(--border-strong);
131
139
  }
132
140
 
133
141
  .mobile-nav-backdrop {
@@ -5,7 +5,7 @@
5
5
  column: brand-strip → first nav item, last nav item → "Sessions"
6
6
  header, and "Sessions" header → first folder. Bump or shrink to
7
7
  adjust how breathy the rail feels. */
8
- --sidebar-section-gap: var(--s-3);
8
+ --sidebar-section-gap: var(--s-2);
9
9
  position: sticky;
10
10
  top: 0;
11
11
  height: 100vh;
@@ -400,7 +400,9 @@ body.is-resizing-sidebar * {
400
400
  display: flex;
401
401
  justify-content: space-between;
402
402
  align-items: center;
403
- padding: 0 10px;
403
+ /* Right padding matches .tree-session so the +-button glyph
404
+ right-aligns with the per-row tree-meta timestamp below. */
405
+ padding: 0 8px;
404
406
  min-height: 24px;
405
407
  height: 24px;
406
408
  font-size: 12px;
@@ -415,7 +417,10 @@ body.is-resizing-sidebar * {
415
417
  appearance: none;
416
418
  background: transparent;
417
419
  border: 0;
418
- padding: 2px 4px;
420
+ /* Zero right padding so the glyph itself lands flush against the
421
+ row's 8px right inset — same column as the tree-meta text on
422
+ each session row. */
423
+ padding: 2px 0 2px 4px;
419
424
  border-radius: 4px;
420
425
  cursor: pointer;
421
426
  color: var(--ink);
@@ -693,9 +698,10 @@ body.is-resizing-sidebar * {
693
698
  .tree-session-action:hover { opacity: 1; background: var(--sidebar-hover); }
694
699
  .tree-session-action svg { width: 12px; height: 12px; }
695
700
  .tree-meta {
696
- font-size: 13px;
701
+ font-size: 12px;
697
702
  color: var(--ink);
698
703
  font-variant-numeric: tabular-nums;
699
704
  flex-shrink: 0;
700
- opacity: 0.5;
705
+ opacity: 0.55;
706
+ letter-spacing: 0.01em;
701
707
  }