syndes 0.1.0 → 0.2.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,402 @@
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
+ ]);
226
+
227
+ const actions = h('div', { style: 'display:flex;gap:8px' }, [
228
+ h('button.btn.btn--ghost', { text: 'Rename', onclick: () => rename(data, outlet, range) }),
229
+ h('button.btn.btn--ghost', { text: 'Sync now', onclick: () => syncNow(outlet, range) }),
230
+ ]);
231
+
232
+ return card('People', [
233
+ table(
234
+ ['Person', 'Share', 'Tokens', 'Sessions', 'Active', 'Score'],
235
+ rows,
236
+ { align: ['left', 'left', 'right', 'right', 'right', 'right'] },
237
+ ),
238
+ h('span.card__note', {
239
+ text: 'A stale shelf has not synced in six hours — that is a machine that stopped reporting, not a quiet day.',
240
+ }),
241
+ h('div', { style: 'margin-top:auto;display:flex;gap:8px;flex-wrap:wrap' }, [
242
+ h('button.btn.btn--ghost', { text: `Sharing: ${data.config.scope}`, onclick: () => changeScope(data, outlet, range) }),
243
+ h('button.btn.btn--ghost', { text: 'Leave pool', onclick: () => leavePool(outlet, range) }),
244
+ ]),
245
+ ], { actions });
246
+ }
247
+
248
+ // ── Actions ─────────────────────────────────────────────────────────────────
249
+
250
+ async function rename(data, outlet, range) {
251
+ const name = await modal({
252
+ title: 'What should this machine be called?',
253
+ note: 'This is the only name anyone in the pool sees. It is yours to choose.',
254
+ confirmLabel: 'Save',
255
+ input: {
256
+ value: data.me.name,
257
+ maxlength: 40,
258
+ placeholder: 'Your name',
259
+ validate: (value) => (value.trim() ? null : 'A name cannot be empty.'),
260
+ },
261
+ });
262
+ if (name === null) return;
263
+ await api.teamAction('name', { name });
264
+ await refresh(outlet, range);
265
+ }
266
+
267
+ async function changeScope(data, outlet, range) {
268
+ const next = data.config.scope === 'summary' ? 'detailed' : 'summary';
269
+ const ok = await modal({
270
+ title: next === 'detailed' ? 'Share more detail?' : 'Share less detail?',
271
+ note: next === 'detailed'
272
+ ? 'Detailed adds your project ids and tool mix to what the pool can see. Prompt text is never shared at any setting.'
273
+ : 'Summary shares counts, tokens and hours only. No project ids, no tool names.',
274
+ confirmLabel: `Switch to ${next}`,
275
+ });
276
+ if (!ok) return;
277
+ await api.teamAction('scope', { scope: next });
278
+ await refresh(outlet, range);
279
+ }
280
+
281
+ async function leavePool(outlet, range) {
282
+ const ok = await modal({
283
+ title: 'Leave the pool?',
284
+ note: 'Your shelf stays where it is until someone deletes it. Your own ledger is untouched.',
285
+ confirmLabel: 'Leave',
286
+ });
287
+ if (!ok) return;
288
+ await api.teamAction('leave');
289
+ await refresh(outlet, range);
290
+ }
291
+
292
+ async function syncNow(outlet, range) {
293
+ await api.teamAction('sync');
294
+ await refresh(outlet, range);
295
+ }
296
+
297
+ async function refresh(outlet, range) {
298
+ const data = await api.team(range);
299
+ if (!outlet.isConnected) return;
300
+ if (!data.enabled) {
301
+ outlet.replaceChildren(setup(range, outlet));
302
+ return;
303
+ }
304
+ paint(outlet, data, range, { live: true });
305
+ }
306
+
307
+ // ── Setup ───────────────────────────────────────────────────────────────────
308
+
309
+ /**
310
+ * Joining a pool.
311
+ *
312
+ * Stated up front, before the field: what leaves the machine, and what does
313
+ * not. Somebody about to point their working history at a git remote is owed
314
+ * that in the same breath as the input box, not in a settings page they will
315
+ * never open.
316
+ */
317
+ function setup(range, outlet) {
318
+ const repo = h('input.field', {
319
+ type: 'text',
320
+ placeholder: 'git@github.com:you/team-usage.git',
321
+ autocomplete: 'off',
322
+ spellcheck: 'false',
323
+ });
324
+ const name = h('input.field', { type: 'text', placeholder: 'Your name', maxlength: '40', autocomplete: 'off' });
325
+ const error = h('div.unlock__error', { role: 'alert' });
326
+ const button = h('button.btn.btn--lime', { type: 'submit', text: 'Join the pool' });
327
+
328
+ const form = h('form', { style: 'display:grid;gap:var(--s4)' }, [
329
+ h('label', { style: 'display:grid;gap:6px' }, [
330
+ h('span.figure__label', { text: 'Repository' }),
331
+ repo,
332
+ 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.' }),
333
+ ]),
334
+ h('label', { style: 'display:grid;gap:6px' }, [
335
+ h('span.figure__label', { text: 'Your name' }),
336
+ name,
337
+ ]),
338
+ button,
339
+ error,
340
+ ]);
341
+
342
+ form.addEventListener('submit', async (event) => {
343
+ event.preventDefault();
344
+ const url = repo.value.trim();
345
+ if (!url) { error.textContent = 'A repository url is required.'; return; }
346
+
347
+ button.disabled = true;
348
+ button.textContent = 'Joining…';
349
+ try {
350
+ if (name.value.trim()) await api.teamAction('name', { name: name.value.trim() });
351
+ await api.teamAction('join', { transport: transportFor(url), repo: url });
352
+ await refresh(outlet, range);
353
+ } catch (caught) {
354
+ error.textContent = caught.body?.error ?? 'Could not reach that repository.';
355
+ button.disabled = false;
356
+ button.textContent = 'Join the pool';
357
+ }
358
+ });
359
+
360
+ return h('div', { style: 'display:grid;place-items:center;height:100%;overflow-y:auto' }, [
361
+ h('div', { style: 'width:min(560px,100%);display:grid;gap:var(--s5)' }, [
362
+ h('div', { style: 'display:grid;gap:10px' }, [
363
+ h('div', {
364
+ style: 'font-size:24px;font-weight:750;letter-spacing:-0.02em;text-transform:uppercase',
365
+ text: 'One account, several people',
366
+ }),
367
+ h('p.unlock__note', {
368
+ style: 'margin:0;text-align:left;line-height:1.7',
369
+ 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.',
370
+ }),
371
+ ]),
372
+
373
+ card('What leaves this machine', [
374
+ h('div', { style: 'display:grid;gap:9px' }, [
375
+ shared(true, 'Daily counts: tokens, sessions, prompts, tool calls, active time'),
376
+ shared(true, 'The hours of the day you worked, so overlap can be spotted'),
377
+ shared(false, 'Your prompts. Never, at any setting.'),
378
+ shared(false, 'File paths, commands, project names — not at the default setting'),
379
+ ]),
380
+ ]),
381
+
382
+ form,
383
+ ]),
384
+ ]);
385
+ }
386
+
387
+ function shared(yes, text) {
388
+ return h('div', { style: 'display:flex;align-items:flex-start;gap:10px;font-size:12px;color:var(--ink-2)' }, [
389
+ h('span', {
390
+ style: `flex:none;margin-top:1px;font-weight:800;color:var(--${yes ? 'lime' : 'orange'})`,
391
+ text: yes ? '+' : '−',
392
+ 'aria-hidden': 'true',
393
+ }),
394
+ h('span', { text }),
395
+ ]);
396
+ }
397
+
398
+ /** A path is a folder pool; anything with a scheme or a colon is a git remote. */
399
+ function transportFor(url) {
400
+ if (/^(https?:\/\/|git@|ssh:\/\/|git:\/\/)/.test(url)) return 'git';
401
+ return url.endsWith('.git') ? 'git' : 'folder';
402
+ }
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.2.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.
@@ -27,6 +27,7 @@ import { commitsSince, branchOf, headOf } from '../collect/git.mjs';
27
27
  import { updateRollups } from '../analytics/rollup.mjs';
28
28
  import { runCoach } from '../practices/engine.mjs';
29
29
  import { pollTails } from '../collect/tail.mjs';
30
+ import { isEnabled as sharingOn, syncOnce, loadState as teamState, teamConfig } from '../sync/index.mjs';
30
31
 
31
32
  /** Events after which the user has paused, so coaching may run. */
32
33
  const BREAKPOINTS = new Set(['Stop', 'SessionEnd', 'SubagentStop']);
@@ -92,7 +93,9 @@ async function run() {
92
93
  if (appended) await updateRollups();
93
94
  if (sawBreakpoint) await runCoach();
94
95
 
95
- return { appended, tailed: tailed.length };
96
+ const shared = shareWithPool();
97
+
98
+ return { appended, tailed: tailed.length, shared };
96
99
  }
97
100
 
98
101
  /** @returns {{drafts: object[], touched: Map, breakpoint: boolean}} */
@@ -233,6 +236,37 @@ function followOtherAgents() {
233
236
  }
234
237
  }
235
238
 
239
+ /**
240
+ * Publish to the shared pool, if there is one.
241
+ *
242
+ * Throttled hard and deliberately. Without this the pool only ever updates
243
+ * while somebody has a dashboard open, which makes the feature useless in the
244
+ * direction that matters: you want to see what the OTHER person used, and they
245
+ * are not sitting in front of a dashboard — they are working.
246
+ *
247
+ * But the worker runs at every breakpoint, and pushing to a remote that often
248
+ * would be a commit per turn and a network call in a loop nobody asked for. So
249
+ * it exchanges at most once every `team.syncMinutes`, and a failure is noted
250
+ * and dropped: a pool that cannot be reached must never hold up the ledger,
251
+ * which is the only thing here that is actually the record.
252
+ */
253
+ function shareWithPool() {
254
+ try {
255
+ if (!sharingOn()) return false;
256
+
257
+ const every = Math.max(1, Number(teamConfig().syncMinutes ?? 10)) * 60_000;
258
+ const last = teamState().at ?? 0;
259
+ if (Date.now() - last < every) return false;
260
+
261
+ const state = syncOnce();
262
+ debug('team sync from worker', state);
263
+ return state.ok;
264
+ } catch (error) {
265
+ debug('team sync failed', error?.message);
266
+ return false;
267
+ }
268
+ }
269
+
236
270
  /** A session that stopped reporting ended without a hook. Say so, do not guess a clean close. */
237
271
  function reap() {
238
272
  return sessions.reapStale().map(({ id, session }) => draft(KIND.SESSION_END, {
package/src/briefing.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  bold, grey, cyan, green, write, pad, stream,
10
10
  } from './term.mjs';
11
11
  import { loadConfig } from '../runtime/config.mjs';
12
+ import { isEnabled as sharingOn, teamConfig } from '../sync/index.mjs';
12
13
  import { existsSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
13
14
  import { dirname } from 'node:path';
14
15
  import { dataDir, displayPath, briefingPending } from '../runtime/paths.mjs';
@@ -49,7 +50,19 @@ export async function runBriefing(result) {
49
50
  write(` ${rule}`);
50
51
  await stream(` ${grey('SynDes keeps a tamper-evident ledger of how you work with coding')}`);
51
52
  await stream(` ${grey('agents, scores it, and tells you the few habits worth changing.')}`);
52
- await stream(` ${grey('It runs entirely on this machine. Nothing is sent anywhere.')}`);
53
+
54
+ // "Nothing is sent anywhere" is the strongest claim this tool makes, and the
55
+ // moment sharing is on it is false. Printing it anyway — three lines after the
56
+ // user watched a push succeed — would teach them that the rest of what this
57
+ // briefing says cannot be trusted either.
58
+ if (sharingOn()) {
59
+ const scope = teamConfig().scope ?? 'summary';
60
+ await stream(` ${grey('It runs on this machine. Sharing is')} ${bold('on')}${grey(', so daily counts, tokens')}`);
61
+ await stream(` ${grey(`and working hours go to your pool (${scope}). Prompts, file paths and`)}`);
62
+ await stream(` ${grey('commands never leave.')} ${cyan('syndes team status')}`);
63
+ } else {
64
+ await stream(` ${grey('It runs entirely on this machine. Nothing is sent anywhere.')}`);
65
+ }
53
66
  write();
54
67
 
55
68
  await stream(` ${bold('EVERYDAY')}`);