@hraness/dawg 0.0.0-stage → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/DAWG.md +142 -0
  3. package/LICENSE +21 -0
  4. package/README.md +164 -2
  5. package/core/drums.ts +102 -0
  6. package/core/loop.ts +78 -0
  7. package/core/score.ts +1065 -0
  8. package/package.json +42 -4
  9. package/src/agent/agent.ts +587 -0
  10. package/src/agent/brief.ts +140 -0
  11. package/src/agent/gateway.ts +267 -0
  12. package/src/agent/ops.ts +172 -0
  13. package/src/agent/planner.ts +221 -0
  14. package/src/agent/provider.ts +326 -0
  15. package/src/agent/sse.ts +90 -0
  16. package/src/agent/tools.ts +976 -0
  17. package/src/agent/xcb-agent.ts +247 -0
  18. package/src/agent/xcb.ts +367 -0
  19. package/src/audio/clock.ts +68 -0
  20. package/src/audio/engine.ts +467 -0
  21. package/src/audio/lock.ts +57 -0
  22. package/src/audio/player.ts +119 -0
  23. package/src/audio/wav.ts +647 -0
  24. package/src/auth/cli.ts +112 -0
  25. package/src/auth/credentials.ts +285 -0
  26. package/src/auth/login.ts +434 -0
  27. package/src/auth/runner.ts +184 -0
  28. package/src/auth/tui.ts +90 -0
  29. package/src/commands/history.ts +56 -0
  30. package/src/commands/music.ts +444 -0
  31. package/src/daemon.ts +31 -0
  32. package/src/main.ts +1332 -0
  33. package/src/render.ts +99 -0
  34. package/src/session/attach.ts +182 -0
  35. package/src/session/client.ts +498 -0
  36. package/src/session/daemon.ts +723 -0
  37. package/src/session/list.ts +180 -0
  38. package/src/session/lock.ts +92 -0
  39. package/src/session/meta.ts +253 -0
  40. package/src/session/naming.ts +464 -0
  41. package/src/session/port.ts +461 -0
  42. package/src/session/presence.ts +159 -0
  43. package/src/session/protocol.ts +618 -0
  44. package/src/session/rebase.ts +138 -0
  45. package/src/session/store.ts +486 -0
  46. package/tui/activity.ts +325 -0
  47. package/tui/app.ts +1129 -0
  48. package/tui/drums.ts +21 -0
  49. package/tui/highway.ts +905 -0
  50. package/tui/input.ts +63 -0
  51. package/tui/keys.ts +102 -0
  52. package/tui/layers.ts +68 -0
  53. package/tui/prompt.ts +609 -0
  54. package/tui/render.ts +124 -0
  55. package/tui/screen.ts +247 -0
  56. package/tui/text.ts +72 -0
  57. package/tui/theme.ts +451 -0
@@ -0,0 +1,618 @@
1
+ import { createHash } from "node:crypto";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import {
5
+ MetaValidationError,
6
+ parseMetaExpect,
7
+ parseMetaPatch,
8
+ parseSessionMeta,
9
+ type MetaExpect,
10
+ type MetaPatch,
11
+ type SessionMeta,
12
+ } from "./meta.ts";
13
+ import type { SessionEvent, SessionPaths, SessionRecord } from "./store.ts";
14
+
15
+ /**
16
+ * dawgd wire protocol: newline-delimited JSON over a Unix domain socket.
17
+ * Every frame carries `v`; every inbound frame is parsed from `unknown` and
18
+ * bounded before it can reach the reducer, the store, or the transport.
19
+ */
20
+ export const PROTOCOL_VERSION = 1 as const;
21
+ /** Client intents carry at most one composition plus a bounded payload. */
22
+ export const MAX_CLIENT_FRAME_BYTES = 2 * 1024 * 1024;
23
+ /** Snapshots carry a full session record, which the store bounds at 4 MiB. */
24
+ export const MAX_SERVER_FRAME_BYTES = 8 * 1024 * 1024;
25
+ const MAX_ID_LENGTH = 64;
26
+ const MAX_KIND_LENGTH = 128;
27
+ const MAX_OPERATIONS = 256;
28
+ const MAX_PRESENCE = 256;
29
+ /** macOS sun_path is 104 bytes including the terminator; Linux is 108. */
30
+ const MAX_SOCKET_PATH_BYTES = 100;
31
+
32
+ export type TransportAction = "play" | "pause" | "toggle" | "seek" | "tempo";
33
+
34
+ export type TransportState = {
35
+ /** Daemon-assigned, strictly increasing per daemon process. */
36
+ seq: number;
37
+ playing: boolean;
38
+ /** Beat position at `atMs`. */
39
+ beat: number;
40
+ bpm: number;
41
+ /** Daemon monotonic clock expressed on the epoch (timeOrigin + now). */
42
+ atMs: number;
43
+ };
44
+
45
+ /** One live window. dawgd drops the entry when the client disconnects. */
46
+ export type PresenceEntry = {
47
+ clientId: string;
48
+ pid: number;
49
+ label: string;
50
+ focusedTrackId: string | null;
51
+ };
52
+
53
+ export type ClientMessage =
54
+ | {
55
+ v: 1;
56
+ type: "hello";
57
+ pid: number;
58
+ label: string;
59
+ clientId: string;
60
+ focusedTrackId: string | null;
61
+ }
62
+ | { v: 1; type: "focus"; id: string; trackId: string | null }
63
+ /** `draft` asks dawgd to reserve a new `track-N` id when all are open. */
64
+ | { v: 1; type: "claim"; id: string; preferred?: string; draft?: boolean }
65
+ /** Conditional metadata write (rename, auto-name); never bumps the revision. */
66
+ | { v: 1; type: "meta"; id: string; patch: MetaPatch; expect?: MetaExpect }
67
+ | {
68
+ v: 1;
69
+ type: "apply";
70
+ id: string;
71
+ key: string;
72
+ base: number;
73
+ kind: string;
74
+ payload: unknown;
75
+ operations?: unknown[];
76
+ composition?: unknown;
77
+ }
78
+ | {
79
+ v: 1;
80
+ type: "transport";
81
+ id: string;
82
+ action: TransportAction;
83
+ beat?: number;
84
+ bpm?: number;
85
+ }
86
+ | { v: 1; type: "sync"; id: string }
87
+ | { v: 1; type: "ping"; id: string };
88
+
89
+ export type ApplyResult =
90
+ | { status: "accepted"; revision: number }
91
+ | { status: "duplicate"; revision: number }
92
+ | {
93
+ status: "rebase";
94
+ baseRevision: number;
95
+ currentRevision: number;
96
+ message: string;
97
+ }
98
+ | { status: "rejected"; code: string; message: string };
99
+
100
+ export type ServerMessage =
101
+ | {
102
+ v: 1;
103
+ type: "welcome";
104
+ sessionId: string;
105
+ pid: number;
106
+ record: SessionRecord<unknown>;
107
+ digest: string;
108
+ transport: TransportState;
109
+ }
110
+ | {
111
+ v: 1;
112
+ type: "commit";
113
+ event: SessionEvent;
114
+ composition: unknown;
115
+ digest: string;
116
+ }
117
+ | {
118
+ v: 1;
119
+ type: "snapshot";
120
+ record: SessionRecord<unknown>;
121
+ digest: string;
122
+ }
123
+ | ({ v: 1; type: "transport" } & TransportState)
124
+ | { v: 1; type: "presence"; clients: PresenceEntry[] }
125
+ | { v: 1; type: "meta"; meta: SessionMeta }
126
+ | {
127
+ v: 1;
128
+ type: "claimed";
129
+ id: string;
130
+ trackId: string | null;
131
+ /** True when `trackId` is a reserved draft not yet in the score. */
132
+ draft?: boolean;
133
+ }
134
+ | ({ v: 1; type: "result"; id: string } & ApplyResult)
135
+ | { v: 1; type: "pong"; id: string }
136
+ | { v: 1; type: "error"; code: string; message: string };
137
+
138
+ export class ProtocolError extends Error {
139
+ public constructor(
140
+ public readonly code: string,
141
+ message: string,
142
+ ) {
143
+ super(message);
144
+ this.name = "ProtocolError";
145
+ }
146
+ }
147
+
148
+ export function encodeFrame(message: ClientMessage | ServerMessage): string {
149
+ return `${JSON.stringify(message)}\n`;
150
+ }
151
+
152
+ /** Splits a byte stream into bounded lines without unbounded buffering. */
153
+ export class LineDecoder {
154
+ private pending = "";
155
+ private pendingBytes = 0;
156
+
157
+ public constructor(private readonly maxBytes: number) {}
158
+
159
+ public push(chunk: string): string[] {
160
+ const lines: string[] = [];
161
+ let start = 0;
162
+ let index = chunk.indexOf("\n");
163
+ while (index >= 0) {
164
+ const piece = chunk.slice(start, index);
165
+ const bytes = this.pendingBytes + Buffer.byteLength(piece, "utf8");
166
+ if (bytes > this.maxBytes)
167
+ throw new ProtocolError("frame-too-large", "frame exceeds limit");
168
+ lines.push(this.pending + piece);
169
+ this.pending = "";
170
+ this.pendingBytes = 0;
171
+ start = index + 1;
172
+ index = chunk.indexOf("\n", start);
173
+ }
174
+ const rest = chunk.slice(start);
175
+ if (rest.length > 0) {
176
+ this.pending += rest;
177
+ this.pendingBytes += Buffer.byteLength(rest, "utf8");
178
+ if (this.pendingBytes > this.maxBytes)
179
+ throw new ProtocolError("frame-too-large", "frame exceeds limit");
180
+ }
181
+ return lines;
182
+ }
183
+ }
184
+
185
+ export function parseClientMessage(line: string): ClientMessage {
186
+ const value = parseObject(line);
187
+ const type = value.type;
188
+ if (type === "hello") {
189
+ const pid = value.pid;
190
+ const label = value.label;
191
+ if (!Number.isSafeInteger(pid) || (pid as number) <= 0)
192
+ throw new ProtocolError("invalid", "hello pid is invalid");
193
+ if (typeof label !== "string" || label.length > MAX_KIND_LENGTH)
194
+ throw new ProtocolError("invalid", "hello label is invalid");
195
+ return {
196
+ v: 1,
197
+ type,
198
+ pid: pid as number,
199
+ label,
200
+ clientId: requireId(value.clientId, "client id"),
201
+ focusedTrackId: optionalTrackId(value.focusedTrackId),
202
+ };
203
+ }
204
+ if (type === "focus")
205
+ return {
206
+ v: 1,
207
+ type,
208
+ id: requireId(value.id, "request id"),
209
+ trackId: optionalTrackId(value.trackId),
210
+ };
211
+ if (type === "claim") {
212
+ const message: ClientMessage = {
213
+ v: 1,
214
+ type,
215
+ id: requireId(value.id, "request id"),
216
+ };
217
+ const preferred = optionalTrackId(value.preferred);
218
+ if (preferred !== null) message.preferred = preferred;
219
+ if (value.draft === true) message.draft = true;
220
+ return message;
221
+ }
222
+ if (type === "meta") {
223
+ const id = requireId(value.id, "request id");
224
+ try {
225
+ const message: ClientMessage = {
226
+ v: 1,
227
+ type,
228
+ id,
229
+ patch: parseMetaPatch(value.patch),
230
+ };
231
+ const expect = parseMetaExpect(value.expect);
232
+ if (expect) message.expect = expect;
233
+ return message;
234
+ } catch (error) {
235
+ if (error instanceof MetaValidationError)
236
+ throw new ProtocolError("invalid", error.message);
237
+ throw error;
238
+ }
239
+ }
240
+ if (type === "apply") {
241
+ const id = requireId(value.id, "request id");
242
+ const key = requireId(value.key, "idempotency key");
243
+ if (!Number.isSafeInteger(value.base) || (value.base as number) < 0)
244
+ throw new ProtocolError("invalid", "base revision is invalid");
245
+ const kind = value.kind;
246
+ if (
247
+ typeof kind !== "string" ||
248
+ kind.length === 0 ||
249
+ kind.length > MAX_KIND_LENGTH
250
+ )
251
+ throw new ProtocolError("invalid", "event kind is invalid");
252
+ if (!("payload" in value) || value.payload === undefined)
253
+ throw new ProtocolError("invalid", "event payload is missing");
254
+ const hasOperations = value.operations !== undefined;
255
+ const hasComposition = value.composition !== undefined;
256
+ if (hasOperations === hasComposition)
257
+ throw new ProtocolError(
258
+ "invalid",
259
+ "apply needs exactly one of operations or composition",
260
+ );
261
+ const message: ClientMessage = {
262
+ v: 1,
263
+ type,
264
+ id,
265
+ key,
266
+ base: value.base as number,
267
+ kind,
268
+ payload: value.payload,
269
+ };
270
+ if (hasOperations) {
271
+ if (
272
+ !Array.isArray(value.operations) ||
273
+ value.operations.length === 0 ||
274
+ value.operations.length > MAX_OPERATIONS
275
+ )
276
+ throw new ProtocolError("invalid", "operations are invalid");
277
+ message.operations = value.operations as unknown[];
278
+ } else message.composition = value.composition;
279
+ return message;
280
+ }
281
+ if (type === "transport") {
282
+ const id = requireId(value.id, "request id");
283
+ const action = value.action;
284
+ if (
285
+ action !== "play" &&
286
+ action !== "pause" &&
287
+ action !== "toggle" &&
288
+ action !== "seek" &&
289
+ action !== "tempo"
290
+ )
291
+ throw new ProtocolError("invalid", "transport action is invalid");
292
+ const message: ClientMessage = { v: 1, type, id, action };
293
+ if (action === "seek") {
294
+ if (!isFiniteNonNegative(value.beat))
295
+ throw new ProtocolError("invalid", "seek beat is invalid");
296
+ message.beat = value.beat as number;
297
+ }
298
+ if (action === "tempo") {
299
+ if (!isFiniteNonNegative(value.bpm) || (value.bpm as number) <= 0)
300
+ throw new ProtocolError("invalid", "tempo bpm is invalid");
301
+ message.bpm = value.bpm as number;
302
+ }
303
+ return message;
304
+ }
305
+ if (type === "sync" || type === "ping")
306
+ return { v: 1, type, id: requireId(value.id, "request id") };
307
+ throw new ProtocolError("unknown-type", "unknown message type");
308
+ }
309
+
310
+ export function parseServerMessage(line: string): ServerMessage {
311
+ const value = parseObject(line);
312
+ const type = value.type;
313
+ if (type === "welcome") {
314
+ if (typeof value.sessionId !== "string")
315
+ throw new ProtocolError("invalid", "welcome session is invalid");
316
+ if (!Number.isSafeInteger(value.pid))
317
+ throw new ProtocolError("invalid", "welcome pid is invalid");
318
+ return {
319
+ v: 1,
320
+ type,
321
+ sessionId: value.sessionId,
322
+ pid: value.pid as number,
323
+ record: requireRecord(value.record),
324
+ digest: requireDigest(value.digest),
325
+ transport: requireTransport(value.transport),
326
+ };
327
+ }
328
+ if (type === "commit") {
329
+ const event = value.event;
330
+ if (
331
+ typeof event !== "object" ||
332
+ event === null ||
333
+ !Number.isSafeInteger((event as { revision?: unknown }).revision) ||
334
+ typeof (event as { id?: unknown }).id !== "string" ||
335
+ typeof (event as { kind?: unknown }).kind !== "string" ||
336
+ typeof (event as { at?: unknown }).at !== "string"
337
+ )
338
+ throw new ProtocolError("invalid", "commit event is invalid");
339
+ if (value.composition === undefined)
340
+ throw new ProtocolError("invalid", "commit composition is missing");
341
+ return {
342
+ v: 1,
343
+ type,
344
+ event: event as SessionEvent,
345
+ composition: value.composition,
346
+ digest: requireDigest(value.digest),
347
+ };
348
+ }
349
+ if (type === "snapshot")
350
+ return {
351
+ v: 1,
352
+ type,
353
+ record: requireRecord(value.record),
354
+ digest: requireDigest(value.digest),
355
+ };
356
+ if (type === "transport") return { v: 1, type, ...requireTransport(value) };
357
+ if (type === "result") {
358
+ const id = requireId(value.id, "request id");
359
+ const status = value.status;
360
+ if (status === "accepted" || status === "duplicate") {
361
+ if (!Number.isSafeInteger(value.revision))
362
+ throw new ProtocolError("invalid", "result revision is invalid");
363
+ return { v: 1, type, id, status, revision: value.revision as number };
364
+ }
365
+ if (status === "rebase") {
366
+ if (
367
+ !Number.isSafeInteger(value.baseRevision) ||
368
+ !Number.isSafeInteger(value.currentRevision)
369
+ )
370
+ throw new ProtocolError("invalid", "rebase revisions are invalid");
371
+ return {
372
+ v: 1,
373
+ type,
374
+ id,
375
+ status,
376
+ baseRevision: value.baseRevision as number,
377
+ currentRevision: value.currentRevision as number,
378
+ message: boundedText(value.message),
379
+ };
380
+ }
381
+ if (status === "rejected")
382
+ return {
383
+ v: 1,
384
+ type,
385
+ id,
386
+ status,
387
+ code: boundedText(value.code),
388
+ message: boundedText(value.message),
389
+ };
390
+ throw new ProtocolError("invalid", "result status is invalid");
391
+ }
392
+ if (type === "presence") {
393
+ if (!Array.isArray(value.clients) || value.clients.length > MAX_PRESENCE)
394
+ throw new ProtocolError("invalid", "presence is invalid");
395
+ return { v: 1, type, clients: value.clients.map(requirePresence) };
396
+ }
397
+ if (type === "meta")
398
+ return { v: 1, type, meta: requireMeta(value.meta, undefined) };
399
+ if (type === "claimed") {
400
+ const message: ServerMessage = {
401
+ v: 1,
402
+ type,
403
+ id: requireId(value.id, "request id"),
404
+ trackId: optionalTrackId(value.trackId),
405
+ };
406
+ if (value.draft === true) message.draft = true;
407
+ return message;
408
+ }
409
+ if (type === "pong")
410
+ return { v: 1, type, id: requireId(value.id, "request id") };
411
+ if (type === "error")
412
+ return {
413
+ v: 1,
414
+ type,
415
+ code: boundedText(value.code),
416
+ message: boundedText(value.message),
417
+ };
418
+ throw new ProtocolError("unknown-type", "unknown message type");
419
+ }
420
+
421
+ /** Stable digest of a composition as stored and broadcast by dawgd. */
422
+ export function compositionDigest(composition: unknown): string {
423
+ return createHash("sha256")
424
+ .update(JSON.stringify(composition) ?? "null")
425
+ .digest("hex")
426
+ .slice(0, 16);
427
+ }
428
+
429
+ /**
430
+ * The socket lives beside the session record when the path fits in sun_path,
431
+ * otherwise under the user's temp directory keyed by a hash of that path.
432
+ */
433
+ export function daemonSocketPath(paths: SessionPaths): string {
434
+ const local = paths.record.replace(/\.json$/, ".sock");
435
+ if (Buffer.byteLength(local, "utf8") <= MAX_SOCKET_PATH_BYTES) return local;
436
+ const hash = createHash("sha256").update(local).digest("hex").slice(0, 24);
437
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
438
+ return join(tmpdir(), `dawgd-${uid}-${hash}.sock`);
439
+ }
440
+
441
+ export function daemonLockPath(paths: SessionPaths): string {
442
+ return `${paths.record}.daemon.lock`;
443
+ }
444
+
445
+ export function daemonLogPath(paths: SessionPaths): string {
446
+ return `${paths.record}.daemon.log`;
447
+ }
448
+
449
+ function parseObject(line: string): Record<string, unknown> {
450
+ let value: unknown;
451
+ try {
452
+ value = JSON.parse(line);
453
+ } catch {
454
+ throw new ProtocolError("invalid-json", "frame is not valid JSON");
455
+ }
456
+ if (typeof value !== "object" || value === null || Array.isArray(value))
457
+ throw new ProtocolError("invalid", "frame must be an object");
458
+ const record = value as Record<string, unknown>;
459
+ if (record.v !== PROTOCOL_VERSION)
460
+ throw new ProtocolError(
461
+ "version",
462
+ `unsupported protocol version; expected ${PROTOCOL_VERSION}`,
463
+ );
464
+ return record;
465
+ }
466
+
467
+ function requireId(value: unknown, label: string): string {
468
+ if (
469
+ typeof value !== "string" ||
470
+ value.length === 0 ||
471
+ value.length > MAX_ID_LENGTH ||
472
+ !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)
473
+ )
474
+ throw new ProtocolError("invalid", `${label} is invalid`);
475
+ return value;
476
+ }
477
+
478
+ /** Track ids are bounded score ids; `null`/absent means no focus. */
479
+ export function optionalTrackId(value: unknown): string | null {
480
+ if (value === undefined || value === null) return null;
481
+ return requireId(value, "track id");
482
+ }
483
+
484
+ export function requirePresence(value: unknown): PresenceEntry {
485
+ if (typeof value !== "object" || value === null)
486
+ throw new ProtocolError("invalid", "presence entry is invalid");
487
+ const entry = value as Record<string, unknown>;
488
+ if (!Number.isSafeInteger(entry.pid) || (entry.pid as number) <= 0)
489
+ throw new ProtocolError("invalid", "presence pid is invalid");
490
+ return {
491
+ clientId: requireId(entry.clientId, "client id"),
492
+ pid: entry.pid as number,
493
+ label: boundedText(entry.label).slice(0, MAX_KIND_LENGTH),
494
+ focusedTrackId: optionalTrackId(entry.focusedTrackId),
495
+ };
496
+ }
497
+
498
+ /**
499
+ * Atomic claim rule shared by dawgd and the file fallback: the preferred
500
+ * track when it exists and is free, otherwise the first free track in score
501
+ * order, otherwise none. `taken` excludes the claimant's own entry.
502
+ */
503
+ export function chooseTrack(
504
+ trackIds: readonly string[],
505
+ taken: ReadonlySet<string>,
506
+ preferred?: string,
507
+ ): string | null {
508
+ if (preferred && trackIds.includes(preferred) && !taken.has(preferred))
509
+ return preferred;
510
+ return trackIds.find((id) => !taken.has(id)) ?? null;
511
+ }
512
+
513
+ /**
514
+ * Claim with draft fallback: the first unfocused track in score order, else
515
+ * a fresh `track-N` id that is neither in the score nor focused by another
516
+ * window (so two late windows reserve different drafts).
517
+ */
518
+ export function claimOrDraft(
519
+ trackIds: readonly string[],
520
+ taken: ReadonlySet<string>,
521
+ preferred?: string,
522
+ ): { trackId: string; draft: boolean } {
523
+ const trackId = chooseTrack(trackIds, taken, preferred);
524
+ if (trackId !== null) return { trackId, draft: false };
525
+ return { trackId: draftTrackId(trackIds, taken), draft: true };
526
+ }
527
+
528
+ export function draftTrackId(
529
+ trackIds: readonly string[],
530
+ taken: ReadonlySet<string>,
531
+ ): string {
532
+ const used = new Set([...trackIds, ...taken]);
533
+ for (let n = trackIds.length + 1; ; n += 1) {
534
+ const id = `track-${n}`;
535
+ if (!used.has(id)) return id;
536
+ }
537
+ }
538
+
539
+ function requireDigest(value: unknown): string {
540
+ if (typeof value !== "string" || !/^[0-9a-f]{1,64}$/.test(value))
541
+ throw new ProtocolError("invalid", "digest is invalid");
542
+ return value;
543
+ }
544
+
545
+ function requireRecord(value: unknown): SessionRecord<unknown> {
546
+ if (typeof value !== "object" || value === null)
547
+ throw new ProtocolError("invalid", "record is invalid");
548
+ const record = value as Record<string, unknown>;
549
+ if (
550
+ typeof record.sessionId !== "string" ||
551
+ !Number.isSafeInteger(record.revision) ||
552
+ typeof record.updatedAt !== "string" ||
553
+ !Array.isArray(record.events) ||
554
+ record.events.length !== record.revision ||
555
+ record.composition === undefined
556
+ )
557
+ throw new ProtocolError("invalid", "record is invalid");
558
+ return {
559
+ ...(record as unknown as SessionRecord<unknown>),
560
+ meta: requireMeta(record.meta, {
561
+ sessionId: record.sessionId,
562
+ updatedAt: record.updatedAt,
563
+ }),
564
+ };
565
+ }
566
+
567
+ function requireMeta(
568
+ value: unknown,
569
+ fallback: { sessionId: string; updatedAt: string } | undefined,
570
+ ): SessionMeta {
571
+ if (fallback === undefined && (value === undefined || value === null))
572
+ throw new ProtocolError("invalid", "meta is missing");
573
+ try {
574
+ return parseSessionMeta(
575
+ value,
576
+ fallback ?? { sessionId: "unknown", updatedAt: "" },
577
+ );
578
+ } catch (error) {
579
+ if (error instanceof MetaValidationError)
580
+ throw new ProtocolError("invalid", error.message);
581
+ throw error;
582
+ }
583
+ }
584
+
585
+ function requireTransport(value: unknown): TransportState {
586
+ if (typeof value !== "object" || value === null)
587
+ throw new ProtocolError("invalid", "transport is invalid");
588
+ const state = value as Record<string, unknown>;
589
+ if (
590
+ !Number.isSafeInteger(state.seq) ||
591
+ typeof state.playing !== "boolean" ||
592
+ !isFiniteNonNegative(state.beat) ||
593
+ !isFiniteNonNegative(state.bpm) ||
594
+ (state.bpm as number) <= 0 ||
595
+ !isFiniteNonNegative(state.atMs)
596
+ )
597
+ throw new ProtocolError("invalid", "transport is invalid");
598
+ return {
599
+ seq: state.seq as number,
600
+ playing: state.playing,
601
+ beat: state.beat as number,
602
+ bpm: state.bpm as number,
603
+ atMs: state.atMs as number,
604
+ };
605
+ }
606
+
607
+ function isFiniteNonNegative(value: unknown): boolean {
608
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
609
+ }
610
+
611
+ function boundedText(value: unknown): string {
612
+ return typeof value === "string" ? value.slice(0, 512) : "";
613
+ }
614
+
615
+ /** Epoch-anchored monotonic milliseconds, comparable across local processes. */
616
+ export function monotonicEpochMs(): number {
617
+ return performance.timeOrigin + performance.now();
618
+ }