syndes 0.3.0 → 0.3.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/bin/cli.mjs CHANGED
@@ -528,7 +528,17 @@ async function cmdDashboard(args) {
528
528
 
529
529
  if (loadConfig().dashboard.openOnStart && !args.includes('--no-open')) openUrl(url);
530
530
 
531
- const stop = async () => { await close(); process.exit(0); };
531
+ // A second ctrl-c means "I do not believe you, leave now" — so the first
532
+ // press shuts down politely and the second one does not wait for it.
533
+ let stopping = false;
534
+ const stop = async () => {
535
+ if (stopping) process.exit(0);
536
+ stopping = true;
537
+ write();
538
+ write(grey(' stopping…'));
539
+ await close();
540
+ process.exit(0);
541
+ };
532
542
  process.on('SIGINT', stop);
533
543
  process.on('SIGTERM', stop);
534
544
  }
@@ -32,6 +32,21 @@ export const MODES = ['open', 'system', 'pin'];
32
32
  /** Lives only in this process: a launch token dies with the server that made it. */
33
33
  let launchToken = null;
34
34
 
35
+ /**
36
+ * This run of the server.
37
+ *
38
+ * A lock means "ask me", and the answer has to be asked again each time the
39
+ * dashboard starts. Without this the session cookie was checked only against
40
+ * auth.json, which survives a restart — so turning on Touch ID or a PIN asked
41
+ * once, and for the next twelve hours anybody who opened the dashboard walked
42
+ * straight in. The lock looked set and was not enforced, which is worse than no
43
+ * lock, because the user believes it.
44
+ *
45
+ * Bound only in `system` and `pin` modes. `open` has no lock to enforce and a
46
+ * session that outlives the process there is a convenience, not a bypass.
47
+ */
48
+ let instance = randomBytes(9).toString('base64url');
49
+
35
50
  // ── Stored state ───────────────────────────────────────────────────────────
36
51
 
37
52
  export function loadAuth() {
@@ -174,6 +189,8 @@ export function systemAvailable() {
174
189
  export function issueToken(now = Date.now()) {
175
190
  const auth = ensureAuth();
176
191
  const payload = { exp: now + SESSION_MS, epoch: auth.sessionEpoch ?? 0, nonce: randomBytes(9).toString('base64url') };
192
+ // Stamp the run that issued it, so a restart asks again.
193
+ if ((auth.mode ?? 'open') !== 'open') payload.inst = instance;
177
194
  const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
178
195
  return `${body}.${sign(body, auth.secret)}`;
179
196
  }
@@ -195,6 +212,12 @@ export function verifyToken(token) {
195
212
 
196
213
  if (Date.now() > (payload.exp ?? 0)) return { ok: false, reason: 'expired' };
197
214
  if ((payload.epoch ?? -1) !== (auth.sessionEpoch ?? 0)) return { ok: false, reason: 'revoked' };
215
+
216
+ // Under a lock, a session belongs to the run that issued it. A cookie from a
217
+ // previous run — or one minted while the door was open — does not get in.
218
+ if ((auth.mode ?? 'open') !== 'open' && payload.inst !== instance) {
219
+ return { ok: false, reason: 'the dashboard was restarted' };
220
+ }
198
221
  return { ok: true, reason: null };
199
222
  }
200
223
 
@@ -232,4 +255,16 @@ function safeEqual(a, b) {
232
255
  return timingSafeEqual(Buffer.from(a), Buffer.from(b));
233
256
  }
234
257
 
258
+ /**
259
+ * Forget every session this run issued, as a restart would.
260
+ *
261
+ * Exists for the tests: a restart is otherwise only reproducible by spawning a
262
+ * process, and the property being checked — that a lock is re-asked — deserves
263
+ * a direct test as well as an end-to-end one.
264
+ */
265
+ export function resetInstance() {
266
+ instance = randomBytes(9).toString('base64url');
267
+ return instance;
268
+ }
269
+
235
270
  export { SESSION_MS, ensureAuth };
@@ -42,6 +42,19 @@ export async function startServer({ port, idleTimeoutMinutes } = {}) {
42
42
  send(res, 500, { error: 'internal error' });
43
43
  }); });
44
44
 
45
+ // Every open socket, so shutdown can hang them up.
46
+ //
47
+ // server.close() stops accepting new connections and then waits for the
48
+ // existing ones to finish — which for a browser keep-alive is "eventually"
49
+ // and for an event stream is "never". Without this, ctrl-c printed ^C and
50
+ // hung, and every further press queued another close callback until Node
51
+ // warned about a listener leak at eleven.
52
+ const sockets = new Set();
53
+ server.on('connection', (socket) => {
54
+ sockets.add(socket);
55
+ socket.on('close', () => sockets.delete(socket));
56
+ });
57
+
45
58
  let idleTimer = null;
46
59
  const touch = () => {
47
60
  if (!idleMs) return;
@@ -68,13 +81,36 @@ export async function startServer({ port, idleTimeoutMinutes } = {}) {
68
81
  const token = mintLaunchToken();
69
82
  const base = `http://${HOST}:${server.address().port}`;
70
83
  const url = authMode() === 'open' ? `${base}/?t=${token}` : base;
71
- return {
72
- url,
73
- base,
74
- port: server.address().port,
75
- server,
76
- close: () => new Promise((resolve) => { clearTimeout(idleTimer); server.close(resolve); }),
84
+
85
+ /**
86
+ * Stop listening and hang up.
87
+ *
88
+ * Idempotent: calling it twice must not register a second close callback,
89
+ * because the thing that calls it twice is somebody pressing ctrl-c again
90
+ * when the first press appeared to do nothing.
91
+ */
92
+ let closing = null;
93
+ const close = () => {
94
+ if (closing) return closing;
95
+ closing = new Promise((resolve) => {
96
+ clearTimeout(idleTimer);
97
+ let done = false;
98
+ const finish = () => { if (!done) { done = true; resolve(); } };
99
+
100
+ server.close(finish);
101
+ // Then end what is already open, oldest first — an event stream and a
102
+ // keep-alive both sit here forever otherwise.
103
+ for (const socket of sockets) socket.destroy();
104
+ sockets.clear();
105
+
106
+ // A socket wedged in a kernel buffer must not hold the process open.
107
+ const failsafe = setTimeout(finish, 2000);
108
+ failsafe.unref?.();
109
+ });
110
+ return closing;
77
111
  };
112
+
113
+ return { url, base, port: server.address().port, server, close, sockets };
78
114
  }
79
115
 
80
116
  async function handle(req, res, port) {
@@ -540,6 +540,54 @@ a { color: inherit; text-decoration: none; }
540
540
  .field::placeholder { color: var(--ink-3); }
541
541
  .field:focus-visible { outline: 2px solid var(--lime); outline-offset: 2px; }
542
542
 
543
+ /* A grid that reflows on available width rather than on a breakpoint. Cards
544
+ claim a column while one fits and wrap when it does not, so the same markup
545
+ suits a laptop and a wide monitor without a media query choosing for it. */
546
+ .autogrid {
547
+ display: grid;
548
+ gap: var(--s5);
549
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
550
+ align-items: stretch;
551
+ }
552
+ .autogrid--wide { grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }
553
+ .team { align-content: start; }
554
+
555
+ /* ── People ───────────────────────────────────────────────────────────── */
556
+
557
+ /* Rows, not a table. Stacking within a person rather than across them is what
558
+ lets a narrow card drop a line instead of truncating a name. */
559
+ .people { display: grid; }
560
+ .person {
561
+ display: grid;
562
+ grid-template-columns: auto minmax(0, 1fr) auto;
563
+ gap: var(--s3);
564
+ align-items: start;
565
+ padding: var(--s4) 0;
566
+ border-top: 1px solid var(--line);
567
+ }
568
+ .person:first-child { padding-top: var(--s2); border-top: 0; }
569
+ .person > .avatar { margin-top: 2px; }
570
+
571
+ .person__head { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
572
+ .person__name {
573
+ font-weight: 650;
574
+ min-width: 0;
575
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
576
+ }
577
+ .person__tag { font-size: 11px; color: var(--ink-3); }
578
+ /* Pushed right, and never wrapped away from the name it belongs to. */
579
+ .person__share { margin-left: auto; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
580
+
581
+ .person__stats { display: flex; flex-wrap: wrap; gap: 2px var(--s4); font-size: 12px; color: var(--ink-3); }
582
+ .person__stat b { color: var(--ink); font-weight: 650; font-variant-numeric: tabular-nums; }
583
+
584
+ @media (max-width: 520px) {
585
+ /* Below this the remove control cannot sit beside the row without squeezing
586
+ the name, so it moves under it rather than off the edge. */
587
+ .person { grid-template-columns: auto minmax(0, 1fr); }
588
+ .person > .btn { grid-column: 2; justify-self: start; }
589
+ }
590
+
543
591
  /* The invite line. Monospace and selectable — somebody will always copy it by
544
592
  hand rather than trust the button, and a wrapped command must still be one
545
593
  correct command when it is pasted. */
@@ -6,7 +6,7 @@
6
6
  * 2. What is the pool doing? totals, and whether the numbers are live
7
7
  * 3. Were we in each other's way? the contention grid — the one thing
8
8
  * neither person can see from their own machine
9
- * 4. How did each day break down? the daily split, and the people table
9
+ * 4. How did each day break down? the daily split, and who is in the pool
10
10
  *
11
11
  * The page is live while it is open: it holds one event stream, and the server
12
12
  * polls the pool only while that stream exists. When the view is replaced the
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { api } from '../api.js';
18
18
  import {
19
- h, card, figure, trend, chip, dot, meter, legend, empty, table, modal,
19
+ h, card, figure, trend, chip, dot, meter, legend, empty, modal,
20
20
  compact, usd, duration, percent, when, DASH,
21
21
  } from '../ui.js';
22
22
  import { dotMatrix, laneSplit, splitBar } from '../charts.js';
@@ -24,13 +24,14 @@ import { dotMatrix, laneSplit, splitBar } from '../charts.js';
24
24
  export async function render({ range }) {
25
25
  const data = await api.team(range);
26
26
 
27
- const outlet = h('div.content--fit', {
28
- style: 'display:grid;grid-template-rows:auto minmax(0,1fr);gap:var(--s4);min-height:0',
29
- });
27
+ // Not `content--fit`. That pins the page to the viewport and divides what is
28
+ // left between two rows, which is right for a dense screen and wrong here:
29
+ // with one person in the pool the cards have little in them, and forcing them
30
+ // to fill a tall window leaves most of it empty. Letting the page size to its
31
+ // content and scroll adapts to any window instead of assuming one.
32
+ const outlet = h('div.content.team', {});
30
33
 
31
34
  if (!data.enabled) {
32
- outlet.style.display = 'grid';
33
- outlet.style.gridTemplateRows = 'minmax(0,1fr)';
34
35
  outlet.appendChild(setup(range, outlet));
35
36
  return outlet;
36
37
  }
@@ -79,12 +80,12 @@ function connect(outlet, range) {
79
80
  function paint(outlet, data, range, { live = false } = {}) {
80
81
  const me = data.peers.find((peer) => peer.isMe) ?? null;
81
82
 
83
+ // One auto-fitting grid rather than two fixed rows. Cards claim a column when
84
+ // there is room for one and wrap when there is not, so the same markup works
85
+ // on a laptop and on a wide monitor without a breakpoint deciding for it.
82
86
  outlet.replaceChildren(
83
- h('div.grid.g-3', {}, [shareCard(data, me), poolCard(data, live), contentionCard(data)]),
84
- h('div.grid.fill', { style: 'grid-template-columns:minmax(0,1.1fr) minmax(0,1fr)' }, [
85
- splitCard(data),
86
- peopleCard(data, outlet, range),
87
- ]),
87
+ h('div.autogrid', {}, [shareCard(data, me), poolCard(data, live), contentionCard(data)]),
88
+ h('div.autogrid.autogrid--wide', {}, [splitCard(data), peopleCard(data, outlet, range)]),
88
89
  );
89
90
  }
90
91
 
@@ -106,9 +107,13 @@ function shareCard(data, me) {
106
107
  }) : null,
107
108
  }),
108
109
  h('div', { style: 'display:grid;gap:4px' }, [
109
- h('span.card__note', { text: `Even split would be ${percent(fair)}` }),
110
+ // Meaningless while you are the only one here — "an even split would be
111
+ // 100%" is true and says nothing.
112
+ data.peers.length > 1 ? h('span.card__note', { text: `Even split would be ${percent(fair)}` }) : null,
110
113
  h('span.card__note', {
111
- text: `${compact(me?.totals.tokens ?? 0)} of ${compact(data.totals.tokens)} tokens`,
114
+ text: data.peers.length > 1
115
+ ? `${compact(me?.totals.tokens ?? 0)} of ${compact(data.totals.tokens)} tokens`
116
+ : `${compact(data.totals.tokens)} tokens · nobody else has joined yet`,
112
117
  }),
113
118
  ]),
114
119
  ]),
@@ -169,7 +174,9 @@ function contentionCard(data) {
169
174
  return card('When each of you worked', [
170
175
  h('div', { style: 'display:flex;align-items:baseline;gap:var(--s3)' }, [
171
176
  figure('of working hours overlapped', percent(grid.overlapRate), { small: true }),
172
- overlapping ? chip(`${grid.overlapBands} bands`, 'orange') : chip('no overlap', 'lime'),
177
+ overlapping
178
+ ? chip(`${grid.overlapBands} band${grid.overlapBands === 1 ? '' : 's'}`, 'orange')
179
+ : chip('no overlap', 'lime'),
173
180
  ]),
174
181
  dotMatrix(grid),
175
182
  legend([
@@ -201,56 +208,74 @@ function splitCard(data) {
201
208
  }
202
209
 
203
210
  // 4b. The people
211
+ /**
212
+ * One block per person, not a table.
213
+ *
214
+ * This was a seven-column table, and it did not survive contact with a real
215
+ * card: the name truncated to "Gurupra…", "3h 21m" wrapped and clipped, and the
216
+ * remove control was pushed off the right edge behind a scrollbar. A table needs
217
+ * a width it can rely on, and a card in a reflowing grid cannot promise one.
218
+ *
219
+ * Rows fix that by stacking within each person rather than across them — the
220
+ * name gets the width, the numbers wrap as a group, and nothing is ever cut off.
221
+ */
204
222
  function peopleCard(data, outlet, range) {
205
- const rows = data.peers.map((peer) => [
206
- h('div', { style: 'display:flex;align-items:center;gap:9px;min-width:0' }, [
207
- h('span.avatar', { text: peer.initials, title: peer.host ?? peer.deviceId }),
208
- h('div', { style: 'display:grid;min-width:0' }, [
209
- h('span', {
210
- style: 'font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
211
- text: peer.name,
212
- }),
213
- h('span.card__note', { text: peer.isMe ? 'this machine' : (peer.host ?? DASH) }),
214
- ]),
215
- peer.stale ? chip('stale', 'orange') : null,
223
+ const actions = h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap' }, [
224
+ h('button.btn.btn--lime.btn--sm', { text: 'Invite', onclick: () => invite() }),
225
+ h('button.btn.btn--ghost.btn--sm', { text: 'Rename', onclick: () => rename(data, outlet, range) }),
226
+ h('button.btn.btn--ghost.btn--sm', { text: 'Sync now', onclick: () => syncNow(outlet, range) }),
227
+ ]);
228
+
229
+ return card('People', [
230
+ h('div.people', {}, data.peers.map((peer) => personRow(peer, outlet, range))),
231
+
232
+ data.peers.length === 1
233
+ ? h('span.card__note', { text: 'Only this machine so far. Invite gives you a line to send someone.' })
234
+ : h('span.card__note', { text: 'A stale shelf has not synced in six hours — a machine that stopped reporting, not a quiet day.' }),
235
+
236
+ h('div', { style: 'margin-top:auto;display:flex;gap:8px;flex-wrap:wrap;padding-top:var(--s2)' }, [
237
+ h('button.btn.btn--ghost.btn--sm', { text: `Sharing: ${data.config.scope}`, onclick: () => changeScope(data, outlet, range) }),
238
+ h('button.btn.btn--ghost.btn--sm', { text: 'Leave pool', onclick: () => leavePool(outlet, range) }),
216
239
  ]),
217
- h('div', { style: 'display:grid;gap:5px;min-width:90px' }, [
218
- h('span.tnum', { style: 'font-size:12px', text: percent(peer.share) }),
240
+ ], { actions });
241
+ }
242
+
243
+ function personRow(peer, outlet, range) {
244
+ return h('div.person', {}, [
245
+ h('span.avatar', { text: peer.initials, title: peer.host ?? peer.deviceId }),
246
+
247
+ h('div', { style: 'min-width:0;display:grid;gap:6px' }, [
248
+ h('div.person__head', {}, [
249
+ h('span.person__name', { text: peer.name, title: peer.name }),
250
+ peer.isMe ? h('span.person__tag', { text: 'this machine' }) : null,
251
+ peer.stale ? chip('stale', 'orange') : null,
252
+ h('span.person__share', { text: percent(peer.share) }),
253
+ ]),
254
+
219
255
  meter(peer.share, peer.isMe ? 'lime' : 'white'),
256
+
257
+ // Wraps as a group rather than being squeezed into columns, so a narrow
258
+ // card loses a line instead of losing a number.
259
+ h('div.person__stats', {}, [
260
+ stat(compact(peer.totals.tokens), 'tokens'),
261
+ stat(String(peer.totals.sessions), peer.totals.sessions === 1 ? 'session' : 'sessions'),
262
+ stat(duration(peer.totals.activeMs), 'active'),
263
+ stat(peer.score === null ? DASH : String(peer.score), 'score'),
264
+ ]),
220
265
  ]),
221
- compact(peer.totals.tokens),
222
- String(peer.totals.sessions),
223
- duration(peer.totals.activeMs),
224
- peer.score === null ? DASH : String(peer.score),
266
+
225
267
  peer.isMe
226
- ? h('span', { style: 'color:var(--ink-3)', text: DASH })
268
+ ? null
227
269
  : h('button.btn.btn--ghost.btn--sm', {
228
270
  text: 'Remove',
229
271
  title: `Remove ${peer.name} from the pool`,
230
272
  onclick: () => remove(peer, outlet, range),
231
273
  }),
232
274
  ]);
275
+ }
233
276
 
234
- const actions = h('div', { style: 'display:flex;gap:8px' }, [
235
- h('button.btn.btn--lime', { text: 'Invite', onclick: () => invite() }),
236
- h('button.btn.btn--ghost', { text: 'Rename', onclick: () => rename(data, outlet, range) }),
237
- h('button.btn.btn--ghost', { text: 'Sync now', onclick: () => syncNow(outlet, range) }),
238
- ]);
239
-
240
- return card('People', [
241
- table(
242
- ['Person', 'Share', 'Tokens', 'Sessions', 'Active', 'Score', ''],
243
- rows,
244
- { align: ['left', 'left', 'right', 'right', 'right', 'right', 'right'] },
245
- ),
246
- h('span.card__note', {
247
- text: 'A stale shelf has not synced in six hours — that is a machine that stopped reporting, not a quiet day.',
248
- }),
249
- h('div', { style: 'margin-top:auto;display:flex;gap:8px;flex-wrap:wrap' }, [
250
- h('button.btn.btn--ghost', { text: `Sharing: ${data.config.scope}`, onclick: () => changeScope(data, outlet, range) }),
251
- h('button.btn.btn--ghost', { text: 'Leave pool', onclick: () => leavePool(outlet, range) }),
252
- ]),
253
- ], { actions });
277
+ function stat(value, label) {
278
+ return h('span.person__stat', {}, [h('b', { text: value }), h('span', { text: ` ${label}` })]);
254
279
  }
255
280
 
256
281
  // ── Actions ─────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "syndes",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "SynDes — a tamper-evident ledger of everything you do in Claude Code, an efficiency score built from it, and a local dashboard that shows you how you actually work. Splits one shared account between the people on it. macOS, Windows and Linux. Zero dependencies.",
5
5
  "keywords": [
6
6
  "syndes",