syndes 0.1.0 → 0.3.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.
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Team — one shared account, seen from every machine on it.
3
+ *
4
+ * Four questions, in the order two people sharing a plan actually ask them:
5
+ * 1. Whose share is it? the split, and which way it moved
6
+ * 2. What is the pool doing? totals, and whether the numbers are live
7
+ * 3. Were we in each other's way? the contention grid — the one thing
8
+ * neither person can see from their own machine
9
+ * 4. How did each day break down? the daily split, and the people table
10
+ *
11
+ * The page is live while it is open: it holds one event stream, and the server
12
+ * polls the pool only while that stream exists. When the view is replaced the
13
+ * stream is closed on the next message, because a detached page must not keep
14
+ * a connection — or a git remote — busy on its behalf.
15
+ */
16
+
17
+ import { api } from '../api.js';
18
+ import {
19
+ h, card, figure, trend, chip, dot, meter, legend, empty, table, modal,
20
+ compact, usd, duration, percent, when, DASH,
21
+ } from '../ui.js';
22
+ import { dotMatrix, laneSplit, splitBar } from '../charts.js';
23
+
24
+ export async function render({ range }) {
25
+ const data = await api.team(range);
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
+ });
30
+
31
+ if (!data.enabled) {
32
+ outlet.style.display = 'grid';
33
+ outlet.style.gridTemplateRows = 'minmax(0,1fr)';
34
+ outlet.appendChild(setup(range, outlet));
35
+ return outlet;
36
+ }
37
+
38
+ paint(outlet, data, range);
39
+ connect(outlet, range);
40
+ return outlet;
41
+ }
42
+
43
+ // ── Live ────────────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * The event stream.
47
+ *
48
+ * Reconnection is EventSource's own job, which is most of why it was chosen
49
+ * over a socket. The one thing it will not do is notice that the page it was
50
+ * opened for no longer exists — so every message checks, and the last one closes
51
+ * it. Without that, switching to another view leaves the server polling git
52
+ * forever for an audience of nobody.
53
+ */
54
+ function connect(outlet, range) {
55
+ let source;
56
+ try {
57
+ source = new EventSource(api.teamStreamUrl(range));
58
+ } catch {
59
+ return; // no stream is a static page, not a broken one
60
+ }
61
+
62
+ const stop = () => { try { source.close(); } catch { /* already closed */ } };
63
+
64
+ const onData = (event) => {
65
+ if (!outlet.isConnected) return stop();
66
+ let data;
67
+ try { data = JSON.parse(event.data); } catch { return; }
68
+ paint(outlet, data, range, { live: true });
69
+ };
70
+
71
+ source.addEventListener('snapshot', onData);
72
+ source.addEventListener('update', onData);
73
+ source.addEventListener('error', () => { if (!outlet.isConnected) stop(); });
74
+ window.addEventListener('beforeunload', stop, { once: true });
75
+ }
76
+
77
+ // ── The page ────────────────────────────────────────────────────────────────
78
+
79
+ function paint(outlet, data, range, { live = false } = {}) {
80
+ const me = data.peers.find((peer) => peer.isMe) ?? null;
81
+
82
+ 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
+ ]),
88
+ );
89
+ }
90
+
91
+ // 1. Whose share is it
92
+ function shareCard(data, me) {
93
+ const share = me?.share ?? 0;
94
+ const fair = data.peers.length ? 1 / data.peers.length : 1;
95
+
96
+ return card('Your share', [
97
+ h('div', { style: 'display:flex;align-items:flex-end;gap:var(--s5);flex-wrap:wrap' }, [
98
+ figure('of the pool this period', percent(share), {
99
+ trend: me ? trend(share, share - (me.shareDelta ?? 0), {
100
+ format: (value) => percent(value),
101
+ // More of a shared window is not "good" — it is more, and whether that
102
+ // is fine is between the people sharing it. So the arrow is drawn
103
+ // neutral rather than scoring one of them for using less.
104
+ goodDirection: 'up',
105
+ suffix: 'vs last period',
106
+ }) : null,
107
+ }),
108
+ h('div', { style: 'display:grid;gap:4px' }, [
109
+ h('span.card__note', { text: `Even split would be ${percent(fair)}` }),
110
+ h('span.card__note', {
111
+ text: `${compact(me?.totals.tokens ?? 0)} of ${compact(data.totals.tokens)} tokens`,
112
+ }),
113
+ ]),
114
+ ]),
115
+
116
+ splitBar(data.peers.map((peer) => ({
117
+ label: peer.name, value: peer.totals.tokens, isMe: peer.isMe,
118
+ })), { label: 'share of the pool by person' }),
119
+
120
+ legend([
121
+ { tone: 'lime', label: 'you' },
122
+ { tone: 'white', label: 'everyone else' },
123
+ ], compact(data.totals.tokens)),
124
+ ]);
125
+ }
126
+
127
+ // 2. What is the pool doing
128
+ function poolCard(data, live) {
129
+ const sync = data.sync ?? {};
130
+ const failed = sync.ok === false && sync.error;
131
+
132
+ return card('The pool', [
133
+ h('div.grid', { style: 'grid-template-columns:1fr 1fr;gap:var(--s4)' }, [
134
+ figure('people', String(data.totals.people), { small: true }),
135
+ figure('tokens', compact(data.totals.tokens), {
136
+ small: true,
137
+ trend: trend(data.totals.tokens, data.totals.tokens - (data.totals.deltaTokens ?? 0), { format: compact }),
138
+ }),
139
+ figure('active time', duration(data.totals.activeMs), { small: true }),
140
+ figure('notional cost', usd(data.totals.usd), { small: true }),
141
+ ]),
142
+
143
+ h('div', { style: 'display:grid;gap:6px;margin-top:auto' }, [
144
+ h('div', { style: 'display:flex;align-items:center;gap:8px' }, [
145
+ dot(failed ? 'orange' : live ? 'lime' : 'white'),
146
+ h('span.card__note', {
147
+ text: failed
148
+ ? 'Last sync failed'
149
+ : live ? `Live · refreshing every ${data.config.pollSeconds}s` : 'Connecting…',
150
+ }),
151
+ ]),
152
+ h('span.card__note', { text: `Last exchange ${sync.at ? when(sync.at) : DASH}` }),
153
+ failed ? h('span.card__note', { style: 'color:var(--orange)', text: String(sync.error).slice(0, 120) }) : null,
154
+ h('span.card__note', {
155
+ // Said plainly on the page rather than buried in a doc: on a shared plan
156
+ // the dollars are what these tokens WOULD have cost at API rates, and
157
+ // presenting an estimate as a bill would be the wrong kind of confident.
158
+ text: 'Cost is notional — a shared plan bills a subscription, not tokens.',
159
+ }),
160
+ ]),
161
+ ]);
162
+ }
163
+
164
+ // 3. Were we in each other's way
165
+ function contentionCard(data) {
166
+ const grid = data.grid;
167
+ const overlapping = grid.overlapBands > 0;
168
+
169
+ return card('When each of you worked', [
170
+ h('div', { style: 'display:flex;align-items:baseline;gap:var(--s3)' }, [
171
+ figure('of working hours overlapped', percent(grid.overlapRate), { small: true }),
172
+ overlapping ? chip(`${grid.overlapBands} bands`, 'orange') : chip('no overlap', 'lime'),
173
+ ]),
174
+ dotMatrix(grid),
175
+ legend([
176
+ { tone: 'lime', label: 'one person had the window' },
177
+ { tone: 'orange', label: 'two or more at once' },
178
+ ]),
179
+ h('span.card__note', {
180
+ text: overlapping
181
+ ? 'Orange bands are where you were both spending the same rate limit.'
182
+ : 'Nobody has collided in this period.',
183
+ }),
184
+ ], { note: `${grid.bandHours}-hour bands` });
185
+ }
186
+
187
+ // 4a. How did each day break down
188
+ function splitCard(data) {
189
+ const rows = data.lanes.filter((row) => row.total > 0);
190
+ if (!rows.length) return card('Daily split', [empty('No shared activity in this range yet.')]);
191
+
192
+ return card('Daily split', [
193
+ h('div.card__scroll', {}, [
194
+ laneSplit(rows, { format: compact }),
195
+ ]),
196
+ legend([
197
+ { tone: 'lime', label: 'you' },
198
+ { tone: 'white', label: 'everyone else' },
199
+ ], compact(data.totals.tokens)),
200
+ ], { note: 'newest first' });
201
+ }
202
+
203
+ // 4b. The people
204
+ 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,
216
+ ]),
217
+ h('div', { style: 'display:grid;gap:5px;min-width:90px' }, [
218
+ h('span.tnum', { style: 'font-size:12px', text: percent(peer.share) }),
219
+ meter(peer.share, peer.isMe ? 'lime' : 'white'),
220
+ ]),
221
+ compact(peer.totals.tokens),
222
+ String(peer.totals.sessions),
223
+ duration(peer.totals.activeMs),
224
+ peer.score === null ? DASH : String(peer.score),
225
+ peer.isMe
226
+ ? h('span', { style: 'color:var(--ink-3)', text: DASH })
227
+ : h('button.btn.btn--ghost.btn--sm', {
228
+ text: 'Remove',
229
+ title: `Remove ${peer.name} from the pool`,
230
+ onclick: () => remove(peer, outlet, range),
231
+ }),
232
+ ]);
233
+
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 });
254
+ }
255
+
256
+ // ── Actions ─────────────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Invite someone.
260
+ *
261
+ * The output is one line they paste into a terminal — not a URL and a list of
262
+ * steps. Everything they would otherwise have to be told (install it, sign in
263
+ * to GitHub, paste the repo, pick a name) is what `syndes join` then does for
264
+ * them, so the thing being copied here is the whole onboarding.
265
+ *
266
+ * The two facts that are easy to get wrong are stated in the dialog rather than
267
+ * left for someone to discover: the invite is not a key, and a pool is one room
268
+ * where everybody can see everybody.
269
+ */
270
+ async function invite() {
271
+ let details;
272
+ try {
273
+ details = await api.teamInvite();
274
+ } catch (error) {
275
+ await modal({ title: 'Could not make an invite', note: error.body?.error ?? error.message, confirmLabel: 'Close', cancelLabel: 'Dismiss' });
276
+ return;
277
+ }
278
+
279
+ const box = h('code.invite', { text: details.command });
280
+ const copy = h('button.btn.btn--lime', { type: 'button', text: 'Copy the line' });
281
+ copy.addEventListener('click', async () => {
282
+ try {
283
+ await navigator.clipboard.writeText(details.command);
284
+ copy.textContent = 'Copied';
285
+ } catch {
286
+ // Clipboard access can be refused; selecting the text still works.
287
+ copy.textContent = 'Select it and copy';
288
+ }
289
+ setTimeout(() => { copy.textContent = 'Copy the line'; }, 2200);
290
+ });
291
+
292
+ await modal({
293
+ title: `Invite someone to ${details.pool}`,
294
+ note: 'Send them this line. It installs SynDes, checks their GitHub access, offers to fix it, and joins them.',
295
+ body: [
296
+ box,
297
+ h('div', { style: 'display:flex;justify-content:center' }, [copy]),
298
+ h('div.modal__note', { style: 'text-align:left;margin-top:4px' }, [
299
+ h('div', { text: `1. Add them as a collaborator on ${details.repo}` }),
300
+ h('div', { text: '2. They accept the invite on GitHub' }),
301
+ h('div', { text: '3. They paste the line above' }),
302
+ ]),
303
+ h('div.modal__note', { style: 'text-align:left;color:var(--orange)' }, [
304
+ h('div', { text: 'Everyone in a pool can see everyone else in it — names, hours and volumes.' }),
305
+ h('div', { text: 'The line is not a key. Access is the repository\'s collaborator list, and only that.' }),
306
+ ]),
307
+ ],
308
+ confirmLabel: 'Done',
309
+ cancelLabel: 'Close',
310
+ });
311
+ }
312
+
313
+ async function remove(peer, outlet, range) {
314
+ const ok = await modal({
315
+ title: `Remove ${peer.name}?`,
316
+ note: `This takes their published days out of the pool for everyone, so past totals will change. Their own ledger is untouched.`,
317
+ body: [
318
+ h('div.modal__note', { style: 'text-align:left' }, [
319
+ h('div', { text: 'Git keeps the old commits, so this removes them from view, not from history.' }),
320
+ h('div', { text: 'To stop them publishing more, remove their access to the repository as well.' }),
321
+ ]),
322
+ ],
323
+ confirmLabel: `Remove ${peer.name}`,
324
+ cancelLabel: 'Keep them',
325
+ danger: true,
326
+ });
327
+ if (!ok) return;
328
+
329
+ try {
330
+ const result = await api.teamRemove(peer.deviceId);
331
+ if (!result.pushed) {
332
+ await modal({
333
+ title: 'Removed here, but not pushed',
334
+ note: result.error ?? 'The change is local until a push succeeds. Try Sync now.',
335
+ confirmLabel: 'Close', cancelLabel: 'Dismiss',
336
+ });
337
+ }
338
+ } catch (error) {
339
+ await modal({ title: 'Could not remove', note: error.body?.error ?? error.message, confirmLabel: 'Close', cancelLabel: 'Dismiss' });
340
+ }
341
+ await refresh(outlet, range);
342
+ }
343
+
344
+ async function rename(data, outlet, range) {
345
+ const name = await modal({
346
+ title: 'What should this machine be called?',
347
+ note: 'This is the only name anyone in the pool sees. It is yours to choose.',
348
+ confirmLabel: 'Save',
349
+ input: {
350
+ value: data.me.name,
351
+ maxlength: 40,
352
+ placeholder: 'Your name',
353
+ validate: (value) => (value.trim() ? null : 'A name cannot be empty.'),
354
+ },
355
+ });
356
+ if (name === null) return;
357
+ await api.teamAction('name', { name });
358
+ await refresh(outlet, range);
359
+ }
360
+
361
+ async function changeScope(data, outlet, range) {
362
+ const next = data.config.scope === 'summary' ? 'detailed' : 'summary';
363
+ const ok = await modal({
364
+ title: next === 'detailed' ? 'Share more detail?' : 'Share less detail?',
365
+ note: next === 'detailed'
366
+ ? 'Detailed adds your project ids and tool mix to what the pool can see. Prompt text is never shared at any setting.'
367
+ : 'Summary shares counts, tokens and hours only. No project ids, no tool names.',
368
+ confirmLabel: `Switch to ${next}`,
369
+ });
370
+ if (!ok) return;
371
+ await api.teamAction('scope', { scope: next });
372
+ await refresh(outlet, range);
373
+ }
374
+
375
+ async function leavePool(outlet, range) {
376
+ const ok = await modal({
377
+ title: 'Leave the pool?',
378
+ note: 'Your shelf stays where it is until someone deletes it. Your own ledger is untouched.',
379
+ confirmLabel: 'Leave',
380
+ });
381
+ if (!ok) return;
382
+ await api.teamAction('leave');
383
+ await refresh(outlet, range);
384
+ }
385
+
386
+ async function syncNow(outlet, range) {
387
+ await api.teamAction('sync');
388
+ await refresh(outlet, range);
389
+ }
390
+
391
+ async function refresh(outlet, range) {
392
+ const data = await api.team(range);
393
+ if (!outlet.isConnected) return;
394
+ if (!data.enabled) {
395
+ outlet.replaceChildren(setup(range, outlet));
396
+ return;
397
+ }
398
+ paint(outlet, data, range, { live: true });
399
+ }
400
+
401
+ // ── Setup ───────────────────────────────────────────────────────────────────
402
+
403
+ /**
404
+ * Joining a pool.
405
+ *
406
+ * Stated up front, before the field: what leaves the machine, and what does
407
+ * not. Somebody about to point their working history at a git remote is owed
408
+ * that in the same breath as the input box, not in a settings page they will
409
+ * never open.
410
+ */
411
+ function setup(range, outlet) {
412
+ const repo = h('input.field', {
413
+ type: 'text',
414
+ placeholder: 'git@github.com:you/team-usage.git',
415
+ autocomplete: 'off',
416
+ spellcheck: 'false',
417
+ });
418
+ const name = h('input.field', { type: 'text', placeholder: 'Your name', maxlength: '40', autocomplete: 'off' });
419
+ const error = h('div.unlock__error', { role: 'alert' });
420
+ const button = h('button.btn.btn--lime', { type: 'submit', text: 'Join the pool' });
421
+
422
+ const form = h('form', { style: 'display:grid;gap:var(--s4)' }, [
423
+ h('label', { style: 'display:grid;gap:6px' }, [
424
+ h('span.figure__label', { text: 'Repository' }),
425
+ repo,
426
+ h('span.card__note', { text: 'A private repo everyone sharing the account can push to. Your own git credentials are used — SynDes never sees a token.' }),
427
+ ]),
428
+ h('label', { style: 'display:grid;gap:6px' }, [
429
+ h('span.figure__label', { text: 'Your name' }),
430
+ name,
431
+ ]),
432
+ button,
433
+ error,
434
+ ]);
435
+
436
+ form.addEventListener('submit', async (event) => {
437
+ event.preventDefault();
438
+ const url = repo.value.trim();
439
+ if (!url) { error.textContent = 'A repository url is required.'; return; }
440
+
441
+ button.disabled = true;
442
+ button.textContent = 'Joining…';
443
+ try {
444
+ if (name.value.trim()) await api.teamAction('name', { name: name.value.trim() });
445
+ await api.teamAction('join', { transport: transportFor(url), repo: url });
446
+ await refresh(outlet, range);
447
+ } catch (caught) {
448
+ error.textContent = caught.body?.error ?? 'Could not reach that repository.';
449
+ button.disabled = false;
450
+ button.textContent = 'Join the pool';
451
+ }
452
+ });
453
+
454
+ return h('div', { style: 'display:grid;place-items:center;height:100%;overflow-y:auto' }, [
455
+ h('div', { style: 'width:min(560px,100%);display:grid;gap:var(--s5)' }, [
456
+ h('div', { style: 'display:grid;gap:10px' }, [
457
+ h('div', {
458
+ style: 'font-size:24px;font-weight:750;letter-spacing:-0.02em;text-transform:uppercase',
459
+ text: 'One account, several people',
460
+ }),
461
+ h('p.unlock__note', {
462
+ style: 'margin:0;text-align:left;line-height:1.7',
463
+ text: 'Each machine writes its own numbers into a shared repository and reads everyone else’s. Nobody writes anyone else’s file, so there is nothing to merge and no server to run.',
464
+ }),
465
+ ]),
466
+
467
+ card('What leaves this machine', [
468
+ h('div', { style: 'display:grid;gap:9px' }, [
469
+ shared(true, 'Daily counts: tokens, sessions, prompts, tool calls, active time'),
470
+ shared(true, 'The hours of the day you worked, so overlap can be spotted'),
471
+ shared(false, 'Your prompts. Never, at any setting.'),
472
+ shared(false, 'File paths, commands, project names — not at the default setting'),
473
+ ]),
474
+ ]),
475
+
476
+ form,
477
+ ]),
478
+ ]);
479
+ }
480
+
481
+ function shared(yes, text) {
482
+ return h('div', { style: 'display:flex;align-items:flex-start;gap:10px;font-size:12px;color:var(--ink-2)' }, [
483
+ h('span', {
484
+ style: `flex:none;margin-top:1px;font-weight:800;color:var(--${yes ? 'lime' : 'orange'})`,
485
+ text: yes ? '+' : '−',
486
+ 'aria-hidden': 'true',
487
+ }),
488
+ h('span', { text }),
489
+ ]);
490
+ }
491
+
492
+ /** A path is a folder pool; anything with a scheme or a colon is a git remote. */
493
+ function transportFor(url) {
494
+ if (/^(https?:\/\/|git@|ssh:\/\/|git:\/\/)/.test(url)) return 'git';
495
+ return url.endsWith('.git') ? 'git' : 'folder';
496
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "syndes",
3
- "version": "0.1.0",
4
- "description": "SynDes \u2014 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. macOS, Windows and Linux. Zero dependencies.",
3
+ "version": "0.3.0",
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",
7
7
  "claude",
@@ -20,7 +20,10 @@
20
20
  "macos",
21
21
  "windows",
22
22
  "linux",
23
- "cli"
23
+ "cli",
24
+ "team",
25
+ "shared-account",
26
+ "multi-user"
24
27
  ],
25
28
  "license": "MIT",
26
29
  "type": "module",
@@ -39,7 +42,8 @@
39
42
  "notify",
40
43
  "practices",
41
44
  "runtime",
42
- "src"
45
+ "src",
46
+ "sync"
43
47
  ],
44
48
  "engines": {
45
49
  "node": ">=18.0.0"
@@ -46,6 +46,23 @@ export const DEFAULTS = {
46
46
  gzipAfterDays: 30,
47
47
  },
48
48
 
49
+ /**
50
+ * Sharing, off until someone turns it on.
51
+ *
52
+ * `scope` governs what leaves the machine and defaults to the narrower of the
53
+ * two. Raising it is a decision a person makes about their own data, so it is
54
+ * never raised for them — not by an upgrade, and not by joining a pool.
55
+ */
56
+ team: {
57
+ enabled: false,
58
+ transport: null, // 'git' | 'folder'
59
+ repo: null, // a remote url, or the path of a synced folder
60
+ scope: 'summary', // 'summary' | 'detailed' — never prompt text, at any scope
61
+ pollSeconds: 30, // how often an OPEN dashboard refreshes the pool
62
+ syncMinutes: 10, // how often the worker exchanges with no dashboard open
63
+ shareDays: 45, // how much history our shelf keeps, so the pool stays bounded
64
+ },
65
+
49
66
  /** Gap below which two events count as continuous work, for active time. */
50
67
  idleGapMinutes: 5,
51
68
  };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Who this machine is, and what to call the person sitting at it.
3
+ *
4
+ * A shared pool needs a stable name for each shelf, and the obvious candidates
5
+ * are all wrong: a hostname changes when someone renames their laptop, an OS
6
+ * username collides across machines, and a path is not an identity. So the id is
7
+ * minted once, at random, and kept — a rename then changes the label without
8
+ * orphaning a month of history under an id nobody recognises.
9
+ *
10
+ * The display name is the user's to choose. It is the only field in this whole
11
+ * system that a human types about themselves, so it is never inferred from
12
+ * anything that might be an email address or a real name they did not offer.
13
+ */
14
+
15
+ import { hostname, userInfo, platform, release } from 'node:os';
16
+ import { randomBytes } from 'node:crypto';
17
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
18
+ import { dirname } from 'node:path';
19
+ import { deviceFile } from './paths.mjs';
20
+
21
+ let cached = null;
22
+
23
+ /** @returns {{deviceId: string, name: string, host: string, os: string, createdAt: number}} */
24
+ export function identity() {
25
+ if (cached) return cached;
26
+
27
+ let stored = null;
28
+ try {
29
+ stored = JSON.parse(readFileSync(deviceFile, 'utf8'));
30
+ } catch {
31
+ stored = null;
32
+ }
33
+
34
+ if (!stored?.deviceId) {
35
+ stored = {
36
+ deviceId: randomBytes(8).toString('hex'),
37
+ name: null,
38
+ createdAt: Date.now(),
39
+ };
40
+ persist(stored);
41
+ }
42
+
43
+ cached = {
44
+ deviceId: stored.deviceId,
45
+ // Falls back to the account name only because a shelf with no label is
46
+ // unreadable in a list. `syndes team name <you>` replaces it.
47
+ name: stored.name || safeUser(),
48
+ host: safeHost(),
49
+ os: `${platform()} ${release()}`.slice(0, 40),
50
+ createdAt: stored.createdAt ?? Date.now(),
51
+ };
52
+ return cached;
53
+ }
54
+
55
+ export function setName(name) {
56
+ const clean = String(name ?? '').trim().slice(0, 40);
57
+ if (!clean) throw new Error('a name cannot be empty');
58
+
59
+ let stored = {};
60
+ try { stored = JSON.parse(readFileSync(deviceFile, 'utf8')); } catch { /* first run */ }
61
+ stored.deviceId ??= randomBytes(8).toString('hex');
62
+ stored.createdAt ??= Date.now();
63
+ stored.name = clean;
64
+ persist(stored);
65
+ cached = null;
66
+ return identity();
67
+ }
68
+
69
+ /** Two initials for the avatar chips. Ascii only — the chart draws them as SVG text. */
70
+ export function initialsOf(name) {
71
+ const words = String(name ?? '').trim().split(/[\s._-]+/).filter(Boolean);
72
+ if (!words.length) return '??';
73
+ if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
74
+ return (words[0][0] + words[words.length - 1][0]).toUpperCase();
75
+ }
76
+
77
+ function persist(record) {
78
+ try {
79
+ mkdirSync(dirname(deviceFile), { recursive: true });
80
+ writeFileSync(deviceFile, `${JSON.stringify(record, null, 2)}\n`);
81
+ } catch {
82
+ // An unwritable state directory costs a stable id, never a session.
83
+ }
84
+ }
85
+
86
+ function safeUser() {
87
+ try { return userInfo().username?.slice(0, 40) || 'unnamed'; } catch { return 'unnamed'; }
88
+ }
89
+
90
+ function safeHost() {
91
+ try { return hostname().split('.')[0].slice(0, 40); } catch { return 'unknown'; }
92
+ }
93
+
94
+ export function resetCache() {
95
+ cached = null;
96
+ }
package/runtime/paths.mjs CHANGED
@@ -64,6 +64,18 @@ export const cursorsFile = join(stateDir, 'cursors.json');
64
64
  export const sessionsFile = join(stateDir, 'sessions.json');
65
65
  export const coachFile = join(stateDir, 'coach.json');
66
66
  export const projectsFile = join(stateDir, 'projects.json');
67
+ export const deviceFile = join(stateDir, 'device.json');
68
+
69
+ // ── Team sharing ────────────────────────────────────────────────────────────
70
+ // The working copy of the shared pool. Disposable: it is a mirror of a remote
71
+ // (or of a synced folder), never a source of truth. Deleting it costs one pull.
72
+ export const teamDir = join(dataDir, 'team');
73
+ export const teamStateFile = join(stateDir, 'team.json');
74
+
75
+ /** Our own shelf in the pool. A device writes here and NOWHERE else. */
76
+ export function teamDeviceDir(root, deviceId) {
77
+ return join(root, 'devices', deviceId);
78
+ }
67
79
 
68
80
  /**
69
81
  * Set by the npm postinstall, cleared the first time the CLI runs.