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 +11 -1
- package/dashboard/auth.mjs +35 -0
- package/dashboard/server.mjs +42 -6
- package/dashboard/web/app.css +48 -0
- package/dashboard/web/views/team.js +78 -53
- package/package.json +1 -1
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
|
-
|
|
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
|
}
|
package/dashboard/auth.mjs
CHANGED
|
@@ -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 };
|
package/dashboard/server.mjs
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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) {
|
package/dashboard/web/app.css
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
28
|
-
|
|
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.
|
|
84
|
-
h('div.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
206
|
-
h('
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
222
|
-
String(peer.totals.sessions),
|
|
223
|
-
duration(peer.totals.activeMs),
|
|
224
|
-
peer.score === null ? DASH : String(peer.score),
|
|
266
|
+
|
|
225
267
|
peer.isMe
|
|
226
|
-
?
|
|
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
|
-
|
|
235
|
-
|
|
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.
|
|
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",
|