@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,498 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { connect, type Socket } from "node:net";
3
+ import { join } from "node:path";
4
+ import type { MetaExpect, MetaPatch, SessionMeta } from "./meta.ts";
5
+ import {
6
+ daemonSocketPath,
7
+ encodeFrame,
8
+ LineDecoder,
9
+ MAX_SERVER_FRAME_BYTES,
10
+ parseServerMessage,
11
+ type ApplyResult,
12
+ type ClientMessage,
13
+ type PresenceEntry,
14
+ type ServerMessage,
15
+ type TransportAction,
16
+ type TransportState,
17
+ } from "./protocol.ts";
18
+ import {
19
+ sessionPaths,
20
+ type SessionEvent,
21
+ type SessionPaths,
22
+ type SessionRecord,
23
+ } from "./store.ts";
24
+
25
+ export type DaemonClientUpdate =
26
+ | { type: "record"; record: SessionRecord<unknown>; digest: string }
27
+ | { type: "transport"; transport: TransportState }
28
+ | { type: "presence"; clients: PresenceEntry[] }
29
+ | { type: "meta"; meta: SessionMeta }
30
+ | { type: "status"; connected: boolean; message: string };
31
+
32
+ export type DaemonClientOptions = {
33
+ workspace: string;
34
+ sessionId: string;
35
+ label?: string;
36
+ clientId?: string;
37
+ focusedTrackId?: string | null;
38
+ /** Spawn a detached daemon when none answers. Defaults to true. */
39
+ spawn?: boolean;
40
+ /** Extra daemon arguments, for example `--grace-ms` in tests. */
41
+ daemonArgs?: string[];
42
+ connectTimeoutMs?: number;
43
+ requestTimeoutMs?: number;
44
+ };
45
+
46
+ type Pending = {
47
+ frame: string;
48
+ resolve: (message: ServerMessage) => void;
49
+ reject: (error: Error) => void;
50
+ timer: ReturnType<typeof setTimeout>;
51
+ };
52
+
53
+ const DAEMON_ENTRY = join(import.meta.dir, "..", "daemon.ts");
54
+
55
+ export class DaemonUnavailableError extends Error {
56
+ public constructor(message: string) {
57
+ super(message);
58
+ this.name = "DaemonUnavailableError";
59
+ }
60
+ }
61
+
62
+ /**
63
+ * A dawgd session client. It keeps the latest record, digest, transport and
64
+ * presence; reconnects (respawning the daemon) when the socket drops; and
65
+ * resends unanswered requests after reconnecting. Requests carry idempotency
66
+ * keys, so a resend of an already-committed apply is reported as duplicate.
67
+ */
68
+ export class DaemonClient {
69
+ public readonly paths: SessionPaths;
70
+ public readonly socketPath: string;
71
+ public readonly clientId: string;
72
+ public record!: SessionRecord<unknown>;
73
+ public digest = "";
74
+ public transport!: TransportState;
75
+ public presence: PresenceEntry[] = [];
76
+ public daemonPid = 0;
77
+ private socket: Socket | undefined;
78
+ private decoder = new LineDecoder(MAX_SERVER_FRAME_BYTES);
79
+ private readonly pending = new Map<string, Pending>();
80
+ private readonly listeners = new Set<(update: DaemonClientUpdate) => void>();
81
+ private closed = false;
82
+ private reconnecting: Promise<void> | undefined;
83
+ private focusedTrackId: string | null;
84
+ private welcomed: (() => void) | undefined;
85
+
86
+ private constructor(private readonly options: DaemonClientOptions) {
87
+ this.paths = sessionPaths(options.workspace, options.sessionId);
88
+ this.socketPath = daemonSocketPath(this.paths);
89
+ this.clientId = options.clientId ?? randomUUID();
90
+ this.focusedTrackId = options.focusedTrackId ?? null;
91
+ }
92
+
93
+ /** Connects to the session daemon, starting one if none is alive. */
94
+ public static async connect(
95
+ options: DaemonClientOptions,
96
+ ): Promise<DaemonClient> {
97
+ const client = new DaemonClient(options);
98
+ await client.open(options.connectTimeoutMs ?? 4_000);
99
+ return client;
100
+ }
101
+
102
+ public get connected(): boolean {
103
+ return this.socket !== undefined && !this.socket.destroyed;
104
+ }
105
+
106
+ public subscribe(listener: (update: DaemonClientUpdate) => void): () => void {
107
+ this.listeners.add(listener);
108
+ return () => this.listeners.delete(listener);
109
+ }
110
+
111
+ public async apply(intent: {
112
+ base: number;
113
+ kind: string;
114
+ payload: unknown;
115
+ key?: string;
116
+ operations?: unknown[];
117
+ composition?: unknown;
118
+ }): Promise<ApplyResult> {
119
+ const message: ClientMessage = {
120
+ v: 1,
121
+ type: "apply",
122
+ id: randomUUID(),
123
+ key: intent.key ?? randomUUID(),
124
+ base: intent.base,
125
+ kind: intent.kind,
126
+ payload: intent.payload,
127
+ };
128
+ if (intent.operations) message.operations = intent.operations;
129
+ else message.composition = intent.composition;
130
+ return this.result(await this.request(message));
131
+ }
132
+
133
+ public async setTransport(
134
+ action: TransportAction,
135
+ value?: { beat?: number; bpm?: number },
136
+ ): Promise<TransportState> {
137
+ const message: ClientMessage = {
138
+ v: 1,
139
+ type: "transport",
140
+ id: randomUUID(),
141
+ action,
142
+ };
143
+ if (value?.beat !== undefined) message.beat = value.beat;
144
+ if (value?.bpm !== undefined) message.bpm = value.bpm;
145
+ await this.request(message);
146
+ return this.transport;
147
+ }
148
+
149
+ /** Round-trips behind every queued write and returns the latest record. */
150
+ public async sync(): Promise<SessionRecord<unknown>> {
151
+ await this.request({ v: 1, type: "sync", id: randomUUID() });
152
+ return this.record;
153
+ }
154
+
155
+ public async focus(trackId: string | null): Promise<void> {
156
+ this.focusedTrackId = trackId;
157
+ await this.request({ v: 1, type: "focus", id: randomUUID(), trackId });
158
+ }
159
+
160
+ /** Atomically claims a track no other live window is focused on. */
161
+ public async claimTrack(preferred?: string): Promise<string | null> {
162
+ const message: ClientMessage = { v: 1, type: "claim", id: randomUUID() };
163
+ if (preferred) message.preferred = preferred;
164
+ const reply = await this.request(message);
165
+ const trackId = reply.type === "claimed" ? reply.trackId : null;
166
+ if (trackId !== null) this.focusedTrackId = trackId;
167
+ return trackId;
168
+ }
169
+
170
+ /** Like `claimTrack`, but reserves a draft `track-N` when all are open. */
171
+ public async claimOrDraft(
172
+ preferred?: string,
173
+ ): Promise<{ trackId: string; draft: boolean }> {
174
+ const message: ClientMessage = {
175
+ v: 1,
176
+ type: "claim",
177
+ id: randomUUID(),
178
+ draft: true,
179
+ };
180
+ if (preferred) message.preferred = preferred;
181
+ const reply = await this.request(message);
182
+ if (reply.type !== "claimed" || reply.trackId === null)
183
+ throw new Error("dawgd did not reserve a track");
184
+ this.focusedTrackId = reply.trackId;
185
+ return { trackId: reply.trackId, draft: reply.draft === true };
186
+ }
187
+
188
+ /**
189
+ * Conditional metadata write. `stale` means `expect` no longer matched
190
+ * (for example a user rename landed first); `record.meta` is then current.
191
+ */
192
+ public async updateMeta(
193
+ patch: MetaPatch,
194
+ expect?: MetaExpect,
195
+ ): Promise<"applied" | "stale"> {
196
+ const message: ClientMessage = {
197
+ v: 1,
198
+ type: "meta",
199
+ id: randomUUID(),
200
+ patch,
201
+ };
202
+ if (expect) message.expect = expect;
203
+ const result = this.result(await this.request(message));
204
+ if (result.status === "accepted") return "applied";
205
+ if (result.status === "rejected" && result.code === "stale-meta")
206
+ return "stale";
207
+ throw new Error(
208
+ result.status === "rejected" ? result.message : "dawgd meta failed",
209
+ );
210
+ }
211
+
212
+ public close(): void {
213
+ this.closed = true;
214
+ for (const [id, pending] of this.pending) {
215
+ clearTimeout(pending.timer);
216
+ pending.reject(new Error("dawgd client closed"));
217
+ this.pending.delete(id);
218
+ }
219
+ this.socket?.destroy();
220
+ this.socket = undefined;
221
+ this.listeners.clear();
222
+ }
223
+
224
+ private result(message: ServerMessage): ApplyResult {
225
+ if (message.type !== "result")
226
+ throw new Error(`unexpected dawgd reply ${message.type}`);
227
+ if (message.status === "accepted" || message.status === "duplicate")
228
+ return { status: message.status, revision: message.revision };
229
+ if (message.status === "rebase")
230
+ return {
231
+ status: "rebase",
232
+ baseRevision: message.baseRevision,
233
+ currentRevision: message.currentRevision,
234
+ message: message.message,
235
+ };
236
+ return { status: "rejected", code: message.code, message: message.message };
237
+ }
238
+
239
+ private request(
240
+ message: ClientMessage & { id: string },
241
+ ): Promise<ServerMessage> {
242
+ if (this.closed) return Promise.reject(new Error("dawgd client closed"));
243
+ const frame = encodeFrame(message);
244
+ return new Promise((resolve, reject) => {
245
+ const timer = setTimeout(() => {
246
+ this.pending.delete(message.id);
247
+ reject(new DaemonUnavailableError("dawgd did not answer in time"));
248
+ }, this.options.requestTimeoutMs ?? 10_000);
249
+ this.pending.set(message.id, { frame, resolve, reject, timer });
250
+ if (this.connected) this.socket!.write(frame);
251
+ // Otherwise the frame is sent after the reconnect handshake.
252
+ });
253
+ }
254
+
255
+ private async open(timeoutMs: number): Promise<void> {
256
+ const deadline = Date.now() + timeoutMs;
257
+ let lastSpawn = 0;
258
+ while (true) {
259
+ try {
260
+ await this.attach(Math.max(200, deadline - Date.now()));
261
+ return;
262
+ } catch (error) {
263
+ if (this.closed) throw error;
264
+ if (Date.now() >= deadline)
265
+ throw new DaemonUnavailableError(
266
+ `dawgd unavailable: ${error instanceof Error ? error.message : String(error)}`,
267
+ );
268
+ // No listener (ENOENT) or a stale socket from a crashed daemon
269
+ // (ECONNREFUSED): start one. A losing spawn exits on its own because
270
+ // the daemon lock admits exactly one live daemon per session.
271
+ // Respawn at most once a second, in case the first spawn lost a race
272
+ // with a daemon that was already shutting down.
273
+ if (this.options.spawn !== false && Date.now() - lastSpawn > 1_000) {
274
+ this.spawnDaemon();
275
+ lastSpawn = Date.now();
276
+ }
277
+ await new Promise((resolve) => setTimeout(resolve, 40));
278
+ }
279
+ }
280
+ }
281
+
282
+ private spawnDaemon(): void {
283
+ const child = Bun.spawn(
284
+ [
285
+ process.execPath,
286
+ DAEMON_ENTRY,
287
+ "--workspace",
288
+ this.options.workspace,
289
+ "--session",
290
+ this.options.sessionId,
291
+ ...(this.options.daemonArgs ?? []),
292
+ ],
293
+ {
294
+ detached: true,
295
+ stdin: "ignore",
296
+ stdout: "ignore",
297
+ stderr: "ignore",
298
+ env: { ...process.env, DAWG_DAEMON_CHILD: "1" },
299
+ },
300
+ );
301
+ child.unref();
302
+ }
303
+
304
+ /** Connects once and completes the hello/welcome handshake. */
305
+ private attach(timeoutMs: number): Promise<void> {
306
+ return new Promise((resolve, reject) => {
307
+ const socket = connect({ path: this.socketPath });
308
+ let settled = false;
309
+ const timer = setTimeout(
310
+ () => fail(new Error("handshake timed out")),
311
+ timeoutMs,
312
+ );
313
+ const fail = (error: Error) => {
314
+ if (settled) return;
315
+ settled = true;
316
+ clearTimeout(timer);
317
+ socket.destroy();
318
+ reject(error);
319
+ };
320
+ socket.setEncoding("utf8");
321
+ socket.once("error", fail);
322
+ socket.once("connect", () => {
323
+ this.decoder = new LineDecoder(MAX_SERVER_FRAME_BYTES);
324
+ this.welcomed = () => {
325
+ if (settled) return;
326
+ settled = true;
327
+ clearTimeout(timer);
328
+ socket.off("error", fail);
329
+ socket.on("error", () => undefined);
330
+ this.socket = socket;
331
+ // As in dawgd, "end" alone may be all Bun reports for a hang-up.
332
+ socket.on("close", () => this.onClose(socket));
333
+ socket.on("end", () => {
334
+ socket.destroy();
335
+ this.onClose(socket);
336
+ });
337
+ // Resend everything that was unanswered on the previous socket.
338
+ for (const pending of this.pending.values())
339
+ socket.write(pending.frame);
340
+ resolve();
341
+ };
342
+ socket.on("data", (chunk: string) => this.onData(socket, chunk));
343
+ socket.write(
344
+ encodeFrame({
345
+ v: 1,
346
+ type: "hello",
347
+ pid: process.pid,
348
+ label: (this.options.label ?? "dawg").slice(0, 128),
349
+ clientId: this.clientId,
350
+ focusedTrackId: this.focusedTrackId,
351
+ }),
352
+ );
353
+ });
354
+ socket.once("close", () => fail(new Error("socket closed")));
355
+ });
356
+ }
357
+
358
+ private onData(socket: Socket, chunk: string): void {
359
+ let lines: string[];
360
+ try {
361
+ lines = this.decoder.push(chunk);
362
+ } catch {
363
+ socket.destroy();
364
+ return;
365
+ }
366
+ for (const line of lines) {
367
+ if (line.length === 0) continue;
368
+ let message: ServerMessage;
369
+ try {
370
+ message = parseServerMessage(line);
371
+ } catch {
372
+ socket.destroy();
373
+ return;
374
+ }
375
+ this.onMessage(message);
376
+ }
377
+ }
378
+
379
+ private onMessage(message: ServerMessage): void {
380
+ if (message.type === "welcome") {
381
+ this.daemonPid = message.pid;
382
+ this.record = message.record;
383
+ this.digest = message.digest;
384
+ this.transport = message.transport;
385
+ this.welcomed?.();
386
+ this.welcomed = undefined;
387
+ this.emit({ type: "record", record: this.record, digest: this.digest });
388
+ this.emit({ type: "transport", transport: this.transport });
389
+ return;
390
+ }
391
+ if (message.type === "commit") {
392
+ // Commits arrive in order on one socket; a gap means we missed a frame,
393
+ // so ask for a snapshot instead of guessing.
394
+ if (message.event.revision !== this.record.revision + 1) {
395
+ if (message.event.revision > this.record.revision)
396
+ void this.sync().catch(() => undefined);
397
+ return;
398
+ }
399
+ this.record = {
400
+ sessionId: this.record.sessionId,
401
+ revision: message.event.revision,
402
+ updatedAt: message.event.at,
403
+ composition: message.composition,
404
+ events: [...this.record.events, message.event as SessionEvent],
405
+ meta: this.record.meta,
406
+ };
407
+ this.digest = message.digest;
408
+ this.emit({ type: "record", record: this.record, digest: this.digest });
409
+ return;
410
+ }
411
+ if (message.type === "snapshot") {
412
+ const changed =
413
+ message.record.revision !== this.record.revision ||
414
+ message.digest !== this.digest;
415
+ const metaChanged =
416
+ message.record.meta.version !== this.record.meta.version;
417
+ this.record = message.record;
418
+ this.digest = message.digest;
419
+ if (changed)
420
+ this.emit({ type: "record", record: this.record, digest: this.digest });
421
+ if (metaChanged) this.emit({ type: "meta", meta: this.record.meta });
422
+ return;
423
+ }
424
+ if (message.type === "transport") {
425
+ const { v: _v, type: _type, ...state } = message;
426
+ this.transport = state;
427
+ this.emit({ type: "transport", transport: state });
428
+ return;
429
+ }
430
+ if (message.type === "meta") {
431
+ // Metadata has its own version; never regress to an older one.
432
+ if (message.meta.version < this.record.meta.version) return;
433
+ this.record = { ...this.record, meta: message.meta };
434
+ this.emit({ type: "meta", meta: message.meta });
435
+ return;
436
+ }
437
+ if (message.type === "presence") {
438
+ this.presence = message.clients;
439
+ this.emit({ type: "presence", clients: message.clients });
440
+ return;
441
+ }
442
+ if (message.type === "error") {
443
+ this.emit({
444
+ type: "status",
445
+ connected: this.connected,
446
+ message: `dawgd error · ${message.code}`,
447
+ });
448
+ return;
449
+ }
450
+ const pending = this.pending.get(message.id);
451
+ if (!pending) return;
452
+ this.pending.delete(message.id);
453
+ clearTimeout(pending.timer);
454
+ pending.resolve(message);
455
+ }
456
+
457
+ private onClose(socket: Socket): void {
458
+ if (this.socket !== socket) return;
459
+ this.socket = undefined;
460
+ if (this.closed) return;
461
+ this.emit({
462
+ type: "status",
463
+ connected: false,
464
+ message: "dawgd reconnecting",
465
+ });
466
+ this.reconnecting ??= this.reconnect().finally(() => {
467
+ this.reconnecting = undefined;
468
+ });
469
+ }
470
+
471
+ private async reconnect(): Promise<void> {
472
+ let delay = 50;
473
+ while (!this.closed) {
474
+ try {
475
+ await this.open(3_000);
476
+ this.emit({
477
+ type: "status",
478
+ connected: true,
479
+ message: "dawgd reconnected",
480
+ });
481
+ return;
482
+ } catch {
483
+ await new Promise((resolve) => setTimeout(resolve, delay));
484
+ delay = Math.min(1_000, delay * 2);
485
+ }
486
+ }
487
+ }
488
+
489
+ private emit(update: DaemonClientUpdate): void {
490
+ for (const listener of this.listeners) {
491
+ try {
492
+ listener(update);
493
+ } catch {
494
+ // A rendering listener must not break the protocol loop.
495
+ }
496
+ }
497
+ }
498
+ }