@jive-sh/skill-sync 0.4.9 → 0.4.10-latest-darwin-arm64

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.
Files changed (40) hide show
  1. package/multi-call-binary +4 -0
  2. package/package.json +11 -39
  3. package/README.md +0 -93
  4. package/bin/package.json +0 -1
  5. package/bin/skill-sync +0 -576
  6. package/bin/skill-sync-daemon +0 -576
  7. package/dist/daemons/skill-sync-daemon.js +0 -463
  8. package/dist/daemons/skill-sync-daemon.js.map +0 -1
  9. package/dist/entrypoints/skill-sync.js +0 -140
  10. package/dist/entrypoints/skill-sync.js.map +0 -1
  11. package/dist/index.js +0 -10
  12. package/dist/index.js.map +0 -1
  13. package/dist/internal/config.js +0 -149
  14. package/dist/internal/config.js.map +0 -1
  15. package/dist/internal/control.js +0 -101
  16. package/dist/internal/control.js.map +0 -1
  17. package/dist/internal/folders.js +0 -46
  18. package/dist/internal/folders.js.map +0 -1
  19. package/dist/internal/path.js +0 -36
  20. package/dist/internal/path.js.map +0 -1
  21. package/dist/internal/server.js +0 -32
  22. package/dist/internal/server.js.map +0 -1
  23. package/dist/internal/sync.js +0 -162
  24. package/dist/internal/sync.js.map +0 -1
  25. package/dist/internal/usage.js +0 -251
  26. package/dist/internal/usage.js.map +0 -1
  27. package/dist/internal/wire.js +0 -18
  28. package/dist/internal/wire.js.map +0 -1
  29. package/install.cjs +0 -576
  30. package/src/daemons/skill-sync-daemon.ts +0 -457
  31. package/src/entrypoints/skill-sync.ts +0 -143
  32. package/src/index.ts +0 -15
  33. package/src/internal/config.ts +0 -191
  34. package/src/internal/control.ts +0 -127
  35. package/src/internal/folders.ts +0 -49
  36. package/src/internal/path.ts +0 -32
  37. package/src/internal/server.ts +0 -34
  38. package/src/internal/sync.ts +0 -197
  39. package/src/internal/usage.ts +0 -284
  40. package/src/internal/wire.ts +0 -210
@@ -1,284 +0,0 @@
1
- import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
- import { Result, Schema, SchemaIssue } from "effect";
5
- import { isLock } from "./config";
6
- import type { AccountUsage, Money, UsageWindow } from "./wire";
7
-
8
- /**
9
- * How much of its limits each Claude and Codex account on this machine has used, for the dashboard.
10
- *
11
- * Read only from files each tool already writes for itself, never from its credentials, so nothing secret
12
- * is read and nothing is sent anywhere but the numbers. The price is that a reading is only as fresh as
13
- * the tool's last write, so every reading carries the time it was taken, and that neither file format is
14
- * a published contract: a shape this code does not recognise is reported as `unknown`, not guessed at.
15
- *
16
- * - Claude: each account is a `CLAUDE_CONFIG_DIR` (`~/.claude`, `~/.claude-<name>`). Claude Code keeps
17
- * the last plan usage it fetched in the account's `.claude.json` under `cachedUsageUtilization`, and
18
- * refreshes it about every five minutes while a session runs on that account. It is what `/usage`
19
- * shows when the usage endpoint is rate limited.
20
- * - Codex: each account is a `CODEX_HOME` (`~/.codex`, `~/.codex-<name>`). Codex records the rate limits
21
- * its backend reports with every turn, as a `token_count` event in that turn's session file under
22
- * `sessions/`. The fields are Codex's `RateLimitSnapshot` (codex-rs/protocol/src/protocol.rs).
23
- */
24
-
25
- export interface Account {
26
- readonly tool: AccountUsage["tool"];
27
- /** Absolute path of the account's folder. */
28
- readonly path: string;
29
- }
30
-
31
- /** What the readers learn about one account; `readAccountUsage` adds which account it is. */
32
- export type Reading = DistributiveOmit<AccountUsage, "tool" | "home">;
33
- type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
34
-
35
- /** `~/.claude`, `~/.claude-<name>`, `~/.codex` and `~/.codex-<name>`: every account folder in `home`. */
36
- export function detectAccounts(home: string = homedir()): Account[] {
37
- return readdirSync(home)
38
- .filter((entry) => !isLock(entry))
39
- .flatMap((entry): Account[] => {
40
- const tool = entry === ".claude" || entry.startsWith(".claude-") ? "claude"
41
- : entry === ".codex" || entry.startsWith(".codex-") ? "codex"
42
- : undefined;
43
- if (tool === undefined) return [];
44
- const path = join(home, entry);
45
- return isDirectory(path) ? [{ tool, path }] : [];
46
- })
47
- .sort((a, b) => a.path.localeCompare(b.path));
48
- }
49
-
50
- /** Every account on this machine with its usage. One account's unreadable file never hides another's. */
51
- export function readUsage(home: string = homedir()): AccountUsage[] {
52
- return detectAccounts(home).map((account) => {
53
- const name = account.path.slice(home.length).replace(/^[\\/]/, "");
54
- let reading: Reading;
55
- try {
56
- reading = account.tool === "claude" ? readClaude(account.path, home) : readCodex(account.path);
57
- } catch (cause) {
58
- reading = { state: "unknown", reason: `could not read: ${cause instanceof Error ? cause.message : String(cause)}` };
59
- }
60
- return { tool: account.tool, home: name, ...reading };
61
- });
62
- }
63
-
64
- function isDirectory(path: string): boolean {
65
- try {
66
- return statSync(path).isDirectory();
67
- } catch {
68
- return false;
69
- }
70
- }
71
-
72
- const formatIssue = SchemaIssue.makeFormatterDefault();
73
-
74
- /** A record that failed to decode: what failed, on one line, so the dashboard can say why it shows nothing. */
75
- const unknown = (what: string, issue: SchemaIssue.Issue): Reading =>
76
- ({ state: "unknown", reason: `${what} changed shape: ${formatIssue(issue).replace(/\s+/g, " ").slice(0, 160)}` });
77
-
78
- // ── Claude ──────────────────────────────────────────────────────────────────────────────────────────
79
-
80
- /**
81
- * The default account keeps its state in `~/.claude.json`, beside its folder rather than in it; an
82
- * account given its own `CLAUDE_CONFIG_DIR` keeps it inside that folder.
83
- */
84
- export function claudeStatePath(accountPath: string, home: string): string {
85
- return accountPath === join(home, ".claude") ? join(home, ".claude.json") : join(accountPath, ".claude.json");
86
- }
87
-
88
- function readClaude(accountPath: string, home: string): Reading {
89
- const path = claudeStatePath(accountPath, home);
90
- return existsSync(path) ? parseClaudeState(readFileSync(path, "utf-8")) : { state: "none" };
91
- }
92
-
93
- const ClaudeWindow = Schema.Struct({ utilization: Schema.Finite, resets_at: Schema.NullOr(Schema.String) });
94
- const ClaudeMoney = Schema.Struct({ amount_minor: Schema.Finite, currency: Schema.String, exponent: Schema.Int });
95
- const ClaudeUsageCache = Schema.Struct({
96
- fetchedAtMs: Schema.Finite,
97
- utilization: Schema.Struct({
98
- five_hour: Schema.optionalKey(Schema.NullOr(ClaudeWindow)),
99
- seven_day: Schema.optionalKey(Schema.NullOr(ClaudeWindow)),
100
- // Usage credits, the account's spend beyond its plan limits. `limit` has only been seen as null, so a
101
- // limit in another shape is left out rather than failing the whole reading.
102
- spend: Schema.optionalKey(Schema.NullOr(Schema.Struct({ enabled: Schema.Boolean, used: ClaudeMoney, limit: Schema.Unknown }))),
103
- }),
104
- });
105
-
106
- /** Claude's documented window lengths: its status line calls them the 5-hour and 7-day limits. */
107
- const CLAUDE_WINDOWS = [["five_hour", 300], ["seven_day", 7 * 24 * 60]] as const;
108
-
109
- /** Reads the usage Claude Code cached in an account's `.claude.json`. */
110
- export function parseClaudeState(text: string): Reading {
111
- let state: unknown;
112
- try {
113
- state = JSON.parse(text);
114
- } catch {
115
- // Claude Code rewrites the file while it runs; a half-written one reads fine a moment later.
116
- return { state: "unknown", reason: "the account's .claude.json is not valid JSON" };
117
- }
118
- if (typeof state !== "object" || state === null || !("cachedUsageUtilization" in state)) return { state: "none" };
119
- const decoded = Schema.decodeUnknownResult(ClaudeUsageCache)(state.cachedUsageUtilization);
120
- if (Result.isFailure(decoded)) return unknown("Claude Code's usage cache", decoded.failure.issue);
121
- const cache = decoded.success;
122
-
123
- const windows: UsageWindow[] = [];
124
- for (const [key, minutes] of CLAUDE_WINDOWS) {
125
- const window = cache.utilization[key];
126
- if (window === undefined || window === null) continue;
127
- const resetsAt = window.resets_at === null ? null : Date.parse(window.resets_at);
128
- if (resetsAt !== null && !Number.isFinite(resetsAt)) {
129
- return { state: "unknown", reason: `Claude Code's usage cache changed shape: ${key}.resets_at is not a time` };
130
- }
131
- windows.push({ minutes, usedPercent: window.utilization, resetsAt });
132
- }
133
-
134
- const spend = cache.utilization.spend;
135
- const limit = Schema.decodeUnknownOption(ClaudeMoney)(spend?.limit);
136
- return {
137
- state: "reading",
138
- asOf: cache.fetchedAtMs,
139
- windows,
140
- ...(spend?.enabled === true
141
- ? { spent: { used: money(spend.used), limit: limit._tag === "Some" ? money(limit.value) : null } }
142
- : {}),
143
- };
144
- }
145
-
146
- const money = (value: Schema.Schema.Type<typeof ClaudeMoney>): Money =>
147
- ({ amountMinor: value.amount_minor, currency: value.currency, exponent: value.exponent });
148
-
149
- // ── Codex ───────────────────────────────────────────────────────────────────────────────────────────
150
-
151
- const CodexWindow = Schema.Struct({
152
- used_percent: Schema.Finite,
153
- window_minutes: Schema.NullOr(Schema.Finite),
154
- resets_at: Schema.NullOr(Schema.Finite),
155
- });
156
- const CodexTokenCount = Schema.Struct({
157
- timestamp: Schema.String,
158
- payload: Schema.Struct({
159
- type: Schema.Literal("token_count"),
160
- rate_limits: Schema.NullOr(Schema.Struct({
161
- limit_id: Schema.optionalKey(Schema.NullOr(Schema.String)),
162
- primary: Schema.NullOr(CodexWindow),
163
- secondary: Schema.NullOr(CodexWindow),
164
- })),
165
- }),
166
- });
167
-
168
- /** Session files newest first; only this many are searched for a reading before giving up. */
169
- const CODEX_FILES_SEARCHED = 5;
170
- /** How far back from its end a session file is searched. Each turn writes one event, so the last is near the end. */
171
- const CODEX_TAIL_BYTES = 16 * 1024 * 1024;
172
-
173
- function readCodex(accountPath: string): Reading {
174
- const files = sessionFiles(join(accountPath, "sessions"))
175
- .map((path) => ({ path, modifiedAt: statSync(path).mtimeMs }))
176
- .sort((a, b) => b.modifiedAt - a.modifiedAt)
177
- .slice(0, CODEX_FILES_SEARCHED);
178
- // The most recently written file holds the latest event; an older one only matters when the newer
179
- // ones hold none, as for a session run with an API key, which has no plan limits to report.
180
- for (const { path } of files) {
181
- const reading = latestCodexReading(path);
182
- if (reading.state !== "none") return reading;
183
- }
184
- return { state: "none" };
185
- }
186
-
187
- /** Every `rollout-*.jsonl` under `sessions/`, which Codex files by date: `YYYY/MM/DD/rollout-….jsonl`. */
188
- function sessionFiles(dir: string): string[] {
189
- if (!isDirectory(dir)) return [];
190
- return readdirSync(dir, { recursive: true, encoding: "utf-8" })
191
- .filter((path) => /(^|[\\/])rollout-[^\\/]*\.jsonl$/.test(path))
192
- .map((path) => join(dir, path));
193
- }
194
-
195
- /** The last rate-limit reading in one session file, searched from the end, since files reach hundreds of MB. */
196
- export function latestCodexReading(path: string): Reading {
197
- for (const line of linesFromEnd(path, CODEX_TAIL_BYTES)) {
198
- const reading = parseCodexLine(line);
199
- if (reading !== undefined) return reading;
200
- }
201
- return { state: "none" };
202
- }
203
-
204
- /**
205
- * The reading one session-file line carries, or undefined when it carries none: most lines are other
206
- * events, and a `token_count` from a session without plan limits has `rate_limits: null`.
207
- */
208
- export function parseCodexLine(line: string): Reading | undefined {
209
- // Cheap test first: only these lines are worth parsing, and the others can be megabytes.
210
- if (!line.includes("\"token_count\"")) return undefined;
211
- let event: unknown;
212
- try {
213
- event = JSON.parse(line);
214
- } catch {
215
- return undefined; // The last line of a file Codex is still writing.
216
- }
217
- if (!isTokenCount(event)) return undefined;
218
- const decoded = Schema.decodeUnknownResult(CodexTokenCount)(event);
219
- if (Result.isFailure(decoded)) return unknown("Codex's rate-limit record", decoded.failure.issue);
220
- const limits = decoded.success.payload.rate_limits;
221
- // Another metered bucket than the main one; the main one is `codex`, or unnamed in older versions.
222
- if (limits === null || (limits.limit_id !== undefined && limits.limit_id !== null && limits.limit_id !== "codex")) return undefined;
223
- const asOf = Date.parse(decoded.success.timestamp);
224
- if (!Number.isFinite(asOf)) return { state: "unknown", reason: "Codex's rate-limit record changed shape: timestamp is not a time" };
225
- return {
226
- state: "reading",
227
- asOf,
228
- windows: [limits.primary, limits.secondary].flatMap((window) => window === null ? [] : [{
229
- minutes: window.window_minutes,
230
- usedPercent: window.used_percent,
231
- resetsAt: window.resets_at === null ? null : window.resets_at * 1000,
232
- }]),
233
- };
234
- }
235
-
236
- const isTokenCount = (event: unknown): boolean =>
237
- typeof event === "object" && event !== null && "payload" in event
238
- && typeof event.payload === "object" && event.payload !== null
239
- && "type" in event.payload && event.payload.type === "token_count";
240
-
241
- /**
242
- * A file's whole lines, last first, reading at most `limit` bytes back from its end. Lines longer than
243
- * `maxLine` bytes are skipped: holding a multi-megabyte line while reading backwards would copy it once
244
- * per chunk read, and the lines wanted here are a few hundred bytes.
245
- */
246
- export function* linesFromEnd(path: string, limit: number, maxLine = 1024 * 1024): Generator<string> {
247
- const CHUNK = 64 * 1024;
248
- const fd = openSync(path, "r");
249
- try {
250
- const size = statSync(path).size;
251
- const stop = Math.max(0, size - limit);
252
- let position = size;
253
- // The start of a line whose line break has not been read yet, since reading goes backwards.
254
- let carry = Buffer.alloc(0);
255
- // The line being read is longer than `maxLine`; its bytes are dropped until its start is found.
256
- let oversized = false;
257
- while (position > stop) {
258
- const length = Math.min(CHUNK, position - stop);
259
- position -= length;
260
- const chunk = Buffer.alloc(length);
261
- readSync(fd, chunk, 0, length, position);
262
- const buffer = Buffer.concat([chunk, carry]);
263
- // Splitting on the newline byte is safe in UTF-8: it never occurs inside a multi-byte character.
264
- let end = buffer.length;
265
- for (let index = buffer.length - 1; index >= 0; index--) {
266
- if (buffer[index] !== 0x0a) continue;
267
- const line = buffer.subarray(index + 1, end).toString("utf-8").trim();
268
- if (!oversized && line !== "") yield line;
269
- oversized = false;
270
- end = index;
271
- }
272
- carry = buffer.subarray(0, end);
273
- if (carry.length > maxLine) {
274
- carry = Buffer.alloc(0);
275
- oversized = true;
276
- }
277
- }
278
- // The file's first line. When the search stopped short of the start, what is left is part of a line.
279
- const first = carry.toString("utf-8").trim();
280
- if (stop === 0 && !oversized && first !== "") yield first;
281
- } finally {
282
- closeSync(fd);
283
- }
284
- }
@@ -1,210 +0,0 @@
1
- /**
2
- * The shapes the daemon and the service exchange.
3
- *
4
- * Declared in the library and imported by the service so there is one definition rather than two that
5
- * drift. The service depends on the library; nothing goes the other way.
6
- */
7
-
8
- export interface RemoteFile {
9
- readonly path: string;
10
- /** Null when the file is deleted; the row stays, so the deletion reaches every machine. */
11
- readonly hash: string | null;
12
- /** When this version was edited, in milliseconds since the epoch. The latest edit wins. */
13
- readonly editedAt: number;
14
- readonly updatedBy: string;
15
- }
16
-
17
- export interface ListFilesResponse {
18
- /** Highest sequence number in this page; the caller sends it back as `since`. */
19
- readonly cursor: number;
20
- readonly files: ReadonlyArray<RemoteFile>;
21
- }
22
-
23
- /** Taken only when `editedAt` is later than the edit the server holds; refused with 409 otherwise. */
24
- export interface PutFileRequest {
25
- readonly path: string;
26
- /** When the file was edited: its modification time, or for a deletion, when it was noticed. */
27
- readonly editedAt: number;
28
- /** Null to delete. */
29
- readonly hash: string | null;
30
- /** Base64, absent for a delete. */
31
- readonly content: string | null;
32
- readonly machine: string;
33
- }
34
-
35
- /** Where a folder's sync stands, as the dashboard shows it. `attention` means a cycle failed; `detail` says why. */
36
- export type FolderState = "idle" | "attention";
37
-
38
- /**
39
- * A skill is a top-level entry of a skills folder — usually a directory like `jive/` holding `SKILL.md`
40
- * and its files — and it is the unit of opting in: all of its files sync, or none do, so a skill never
41
- * arrives half-present on another machine.
42
- */
43
- export const skillOf = (path: string): string => path.split("/")[0]!;
44
-
45
- /** A file and the SHA-256 of its content — the same hash the server stores, so copies can be compared. */
46
- export interface FileHash {
47
- readonly path: string;
48
- readonly hash: string;
49
- }
50
-
51
- /** One skill as a folder holds it. */
52
- export interface LocalSkill {
53
- readonly name: string;
54
- readonly files: ReadonlyArray<FileHash>;
55
- }
56
-
57
- /**
58
- * `POST /folders`: every folder this machine found, with the skills in each, for the dashboard. Answered
59
- * with the enabled folders, each with the synced list: the daemon pushes and pulls exactly those skills,
60
- * and never touches any other.
61
- */
62
- export interface FoldersRequest {
63
- readonly folders: ReadonlyArray<{ readonly path: string; readonly skills: ReadonlyArray<LocalSkill> }>;
64
- }
65
- export interface FolderPrefs {
66
- readonly path: string;
67
- readonly skills: ReadonlyArray<string>;
68
- }
69
- export interface FoldersResponse {
70
- readonly folders: ReadonlyArray<FolderPrefs>;
71
- }
72
-
73
- /** `GET /skills`: the synced list, as `skill-sync list` shows it. Deleted skills are not on it. */
74
- export interface ListSkillsResponse {
75
- readonly skills: ReadonlyArray<ListedSkill>;
76
- }
77
- export interface ListedSkill {
78
- readonly name: string;
79
- /** Live files on the server; zero for a skill ticked before any machine has pushed it. */
80
- readonly files: number;
81
- /** The latest edit of any of its files, and the display name of the machine it came from; null with no files. */
82
- readonly editedAt: number | null;
83
- readonly editedBy: string | null;
84
- }
85
-
86
- /**
87
- * The header a daemon names its own version in when it opens its WebSocket to the hub, so the dashboard
88
- * can show which version each machine runs. Daemons before 0.4.6 do not send it.
89
- */
90
- export const DAEMON_VERSION_HEADER = "x-skill-sync-version";
91
-
92
- /** One usage limit of an account: how much of it is used and when it starts over. */
93
- export interface UsageWindow {
94
- /** How long the window is, in minutes (300 for five hours, 10080 for a week); null when the tool does not say. */
95
- readonly minutes: number | null;
96
- /** Percent of the limit used, from 0 to 100, as the tool last recorded it. */
97
- readonly usedPercent: number;
98
- /** When the window starts over, in milliseconds since the epoch; null when no window is running. */
99
- readonly resetsAt: number | null;
100
- }
101
-
102
- /** Money as the tool records it: an amount in the currency's smallest unit, so 1234 with exponent 2 is 12.34. */
103
- export interface Money {
104
- readonly amountMinor: number;
105
- readonly currency: string;
106
- readonly exponent: number;
107
- }
108
-
109
- /**
110
- * What a daemon knows about one Claude or Codex account on its machine, read from files the tool itself
111
- * writes. Never a credential: only these numbers leave the machine.
112
- *
113
- * `reading`: the tool's latest record, with the time it was taken (`asOf`); every number is as of then.
114
- * `none`: the tool has not recorded usage for this account yet. `unknown`: the record exists but is in a
115
- * shape this version cannot read, as when a new release of the tool changes it.
116
- */
117
- export type AccountUsage = {
118
- readonly tool: "claude" | "codex";
119
- /** The account's folder in the home directory, such as `.claude-work` or `.codex`. */
120
- readonly home: string;
121
- } & (
122
- | {
123
- readonly state: "reading";
124
- readonly asOf: number;
125
- readonly windows: ReadonlyArray<UsageWindow>;
126
- /** Spent beyond the plan, when the account pays for usage past its limits; absent when it does not. */
127
- readonly spent?: { readonly used: Money; readonly limit: Money | null };
128
- }
129
- | { readonly state: "none" }
130
- | { readonly state: "unknown"; readonly reason: string }
131
- );
132
-
133
- /** Daemon → hub, over the daemon's WebSocket. */
134
- export type DaemonMessage =
135
- | {
136
- readonly type: "status";
137
- readonly folder: string;
138
- readonly state: FolderState;
139
- readonly detail?: string;
140
- /** A one-line summary of what the last cycle changed, when it changed anything. */
141
- readonly activity?: string;
142
- }
143
- /** Every account on the machine, sent on connecting and whenever a number changes. Daemons before 0.4.9 never send it. */
144
- | { readonly type: "usage"; readonly accounts: ReadonlyArray<AccountUsage> };
145
-
146
- /** Hub → daemon. `prefs` replaces what syncs; `changed` means another write landed on the server. */
147
- export type HubToDaemon =
148
- | { readonly type: "prefs"; readonly folders: ReadonlyArray<FolderPrefs> }
149
- | { readonly type: "changed" };
150
-
151
- export interface SkillView {
152
- readonly name: string;
153
- /** On the list of synced skills, which every enabled folder sends and receives. */
154
- readonly synced: boolean;
155
- /** The folder has a copy on disk. */
156
- readonly local: boolean;
157
- readonly files: ReadonlyArray<FileHash>;
158
- }
159
-
160
- export interface FolderView {
161
- readonly path: string;
162
- readonly enabled: boolean;
163
- readonly state: FolderState;
164
- readonly detail: string | null;
165
- readonly lastActivityAt: number | null;
166
- readonly lastActivity: string | null;
167
- readonly skills: ReadonlyArray<SkillView>;
168
- }
169
-
170
- export interface MachineView {
171
- /** The name the machine's daemon uses; it identifies the machine and never changes. */
172
- readonly machine: string;
173
- /** What the dashboard calls it: renamed there, or the machine name until then. */
174
- readonly name: string;
175
- /** True while this machine's daemon holds a connection to the hub. */
176
- readonly online: boolean;
177
- /** The version its connected daemon runs; null while offline, or when the daemon is too old to say. */
178
- readonly daemonVersion: string | null;
179
- /** Each account's usage as its connected daemon last sent it; empty while offline, or from a daemon before 0.4.9. */
180
- readonly accounts: ReadonlyArray<AccountUsage>;
181
- readonly lastSeen: number | null;
182
- readonly folders: ReadonlyArray<FolderView>;
183
- }
184
-
185
- /** A skill on the synced list, as the server holds it: the version every enabled folder converges on. */
186
- export interface SyncedSkill {
187
- readonly name: string;
188
- readonly files: ReadonlyArray<FileHash & { readonly updatedBy: string; readonly editedAt: number }>;
189
- }
190
-
191
- /** Hub → dashboard: the whole picture, resent on every change. It is small, and a diff protocol is not. */
192
- export interface Snapshot {
193
- readonly type: "snapshot";
194
- /** The deployed service version. A dashboard built by another version reloads itself. */
195
- readonly version: string;
196
- /** The `latest` version of this package on npm, which every daemon upgrades to; null if npm could not be reached. */
197
- readonly publishedVersion: string | null;
198
- /** Display name for every machine name that appears anywhere in the snapshot, including old log entries. */
199
- readonly names: Readonly<Record<string, string>>;
200
- readonly skills: ReadonlyArray<SyncedSkill>;
201
- readonly machines: ReadonlyArray<MachineView>;
202
- }
203
-
204
- /** Dashboard → hub. */
205
- export type DashboardMessage =
206
- | { readonly type: "setFolder"; readonly machine: string; readonly path: string; readonly enabled: boolean }
207
- /** Adds a skill to the synced list, or removes it: one list for every machine and folder. */
208
- | { readonly type: "setSkill"; readonly skill: string; readonly synced: boolean }
209
- /** An empty name restores the machine name. */
210
- | { readonly type: "renameMachine"; readonly machine: string; readonly name: string };