@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
package/src/main.ts ADDED
@@ -0,0 +1,1332 @@
1
+ #!/usr/bin/env bun
2
+ import { randomUUID } from "node:crypto";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import { stdin, stdout } from "node:process";
6
+ import {
7
+ ensureSession,
8
+ SessionConflictError,
9
+ type SessionRecord,
10
+ } from "./session/store.ts";
11
+ import { openSessionPort } from "./session/port.ts";
12
+ import { compositionDigest, monotonicEpochMs } from "./session/protocol.ts";
13
+ import {
14
+ formatSessionLine,
15
+ listSessions,
16
+ printSessions,
17
+ } from "./session/list.ts";
18
+ import {
19
+ ALL_TRACKS_OPEN_HINT,
20
+ attachTrack,
21
+ forkSession,
22
+ historyEvents,
23
+ namingTarget,
24
+ pickerLines,
25
+ resolveSessionArg,
26
+ SessionLookupError,
27
+ withTrack,
28
+ } from "./session/attach.ts";
29
+ import { AutoNamer, providerNameGenerator } from "./session/naming.ts";
30
+ import { normalizeSessionName } from "./session/meta.ts";
31
+ import { parsePrompt } from "./agent/ops.ts";
32
+ import { applyMusicCommand, parseMusicCommand } from "./commands/music.ts";
33
+ import { historyTarget, REDO_KIND, UNDO_KIND } from "./commands/history.ts";
34
+ import { drumSnapshotFields } from "../tui/drums.ts";
35
+ import { highwayLayers } from "../tui/layers.ts";
36
+ import { drumVoicePitch, isDrumInstrument } from "../core/drums.ts";
37
+ import {
38
+ describeAgentEvent,
39
+ StaleRevisionError,
40
+ type AgentEvent,
41
+ type AgentHost,
42
+ } from "./agent/agent.ts";
43
+ import { type GatewayModel } from "./agent/gateway.ts";
44
+ import {
45
+ providerFingerprint,
46
+ providerLabel,
47
+ runProviderTurn,
48
+ selectProvider,
49
+ type ProviderSelection,
50
+ } from "./agent/provider.ts";
51
+ import { runAuthCommand } from "./auth/cli.ts";
52
+ import { tuiAuthCommand, xcbPickerItems } from "./auth/tui.ts";
53
+ import { TransportClock } from "./audio/clock.ts";
54
+ import {
55
+ addNote,
56
+ applyScoreOperation,
57
+ createScore,
58
+ scoreFromJSON,
59
+ SCORE_LIMITS,
60
+ type TrackScore,
61
+ type ScoreOperation,
62
+ } from "../core/score.ts";
63
+ import { decodeLoop, encodeLoop } from "../core/loop.ts";
64
+ import type { TrackScoreSnapshot } from "../tui/render.ts";
65
+ import { PromptModel } from "../tui/prompt.ts";
66
+ import { TerminalInputDecoder } from "../tui/input.ts";
67
+ import {
68
+ composeFrame,
69
+ TuiApp,
70
+ type AppView,
71
+ type SyncState,
72
+ } from "../tui/app.ts";
73
+ import { receiptTone } from "../tui/activity.ts";
74
+ import { encodeBuffer } from "../tui/screen.ts";
75
+ import { parseThemeName } from "../tui/theme.ts";
76
+
77
+ const ESC = "\u001b[";
78
+ const HELP_TEXT = `dawg · local-first terminal music workstation
79
+
80
+ Usage:
81
+ dawg [--new] [--session <name|id>] [--track <name>]
82
+ dawg --import <file> --export <file>
83
+ dawg sessions
84
+ dawg render <out.wav> [--session <name|id>] [--import <file>]
85
+
86
+ Usage flags:
87
+ --reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
88
+ --theme <name> default | high-contrast | mono (NO_COLOR forces mono)
89
+
90
+ Prompt:
91
+ Enter submit · Shift-Enter newline · Alt-Enter queue · Ctrl-Q toggle queue
92
+ Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc cancel/close · Ctrl-C exit
93
+ Space on an empty prompt toggles playback
94
+
95
+ Commands:
96
+ play, pause, tempo <bpm>, instrument <name>, volume <0..1>, pan <-1..1>
97
+ automate <lane> at <beat> <value>, clear automation, mute, clear
98
+ lanes: volume pan filter resonance delay-feedback delay-mix
99
+ undo, redo, solo, unsolo, filter <hz> [res], delay <beats> [fb] [mix]
100
+ reverb <mix> [size], reverb off
101
+ instrument kit, hit <voice> at <beat>, pattern <voice> <beats...>|every <step>
102
+ track <name>, bars <count>, extend <count> bars
103
+ /tracks, /status, /export <file>, /import <file>, /model opus-5.5|sol-6.1
104
+ /sessions, /resume [n|name|id], /rename <name>|--auto, /fork [name]
105
+ /log, /view focus|all, /theme default|high-contrast|mono, /motion on|off
106
+ /login [--xcb], /logout, /auth [--check]
107
+
108
+ Auth:
109
+ dawg login [--gateway|--key|--xcb] [--budget <dollars>]
110
+ dawg logout · dawg auth status [--check]
111
+ Unrecognized requests go to the agent once a provider is configured
112
+ (DAWG_PROVIDER=gateway|xcb|auto; DAWG_AI=0 disables the agent).`;
113
+ const args = new Set(process.argv.slice(2));
114
+ const requestedSession = optionValue("--session");
115
+ const explicitTrack = optionValue("--track");
116
+ /** The focused track; claimed at startup unless `--track` is given. */
117
+ let requestedTrack = explicitTrack ?? "main";
118
+ const initialInstrument = isDrumInstrument(requestedTrack) ? "kit" : "sine";
119
+ const importPath = optionValue("--import");
120
+ const exportPath = optionValue("--export");
121
+ if (["login", "logout", "auth"].includes(process.argv[2] ?? ""))
122
+ process.exit(await runAuthCommand(process.argv.slice(2)));
123
+ if (args.has("--help") || args.has("-h")) {
124
+ stdout.write(`${HELP_TEXT}\n`);
125
+ process.exit(0);
126
+ }
127
+ if (process.argv[2] === "sessions") {
128
+ await printSessions(process.cwd(), stdout);
129
+ process.exit(0);
130
+ }
131
+ if (process.argv[2] === "render") {
132
+ const { runRenderCommand } = await import("./render.ts");
133
+ process.exit(
134
+ await runRenderCommand(
135
+ process.argv.slice(2),
136
+ process.cwd(),
137
+ stdout,
138
+ process.stderr,
139
+ ),
140
+ );
141
+ }
142
+ const demo =
143
+ args.has("--demo") || process.env.DAWG_DEMO === "1" || !stdin.isTTY;
144
+
145
+ const initial = createScore({
146
+ tracks: [
147
+ { id: requestedTrack, name: requestedTrack, instrument: initialInstrument },
148
+ ],
149
+ });
150
+ const sessionOptions: { sessionId?: string; setCurrent: boolean } = {
151
+ setCurrent: !requestedSession || args.has("--new"),
152
+ };
153
+ const selectedSession = args.has("--new")
154
+ ? randomUUID()
155
+ : requestedSession === undefined
156
+ ? undefined
157
+ : await resolveSessionArg(process.cwd(), requestedSession).catch(
158
+ (error: unknown) => {
159
+ if (!(error instanceof SessionLookupError)) throw error;
160
+ process.stderr.write(`${error.message}\n`);
161
+ process.exit(1);
162
+ },
163
+ );
164
+ if (selectedSession !== undefined) sessionOptions.sessionId = selectedSession;
165
+ const session = await ensureSession(initial.toJSON(), sessionOptions);
166
+ // dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
167
+ let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
168
+ paths: session.paths,
169
+ sessionId: session.record.sessionId,
170
+ label: explicitTrack ?? "window",
171
+ focusedTrackId: explicitTrack ?? null,
172
+ daemon: !demo,
173
+ });
174
+ let record: SessionRecord<ReturnType<TrackScore["toJSON"]>> =
175
+ port.mode === "daemon" ? await port.load() : session.record;
176
+ let score = scoreFromJSON(record.composition);
177
+ if (importPath) {
178
+ const imported = decodeLoop(await readLoopFile(importPath));
179
+ score = imported;
180
+ record = await port.append(
181
+ record,
182
+ {
183
+ kind: "score.import",
184
+ payload: { path: importPath, before: record.composition },
185
+ },
186
+ score.toJSON(),
187
+ );
188
+ }
189
+ /** True while the focused track is a reserved draft not yet in the score. */
190
+ let draftTrack = false;
191
+ let attachNotice = "";
192
+ if (!demo) {
193
+ const attached = await attachTrack(port, score, explicitTrack);
194
+ requestedTrack = attached.trackId;
195
+ draftTrack = attached.draft;
196
+ attachNotice = attached.draft
197
+ ? ALL_TRACKS_OPEN_HINT
198
+ : attached.reason === "claimed" && score.tracks.length > 1
199
+ ? `opened · ${requestedTrack}`
200
+ : "";
201
+ } else if (explicitTrack === undefined)
202
+ requestedTrack = score.tracks[0]?.id ?? requestedTrack;
203
+ if (!draftTrack) await ensureFocusedTrack();
204
+
205
+ const clock = new TransportClock(score.tempoBpm);
206
+ let audio = port.player;
207
+ let selectedModel: GatewayModel =
208
+ process.env.DAWG_MODEL === "opus-5.5" ? "opus-5.5" : "sol-6.1";
209
+ const prompt = new PromptModel({ width: 72, maxVisualRows: 8 });
210
+ const tui = new TuiApp({
211
+ io: {
212
+ write: (data) => stdout.write(data),
213
+ columns: () => stdout.columns ?? 80,
214
+ rows: () => stdout.rows ?? 24,
215
+ },
216
+ prompt,
217
+ theme: parseThemeName(optionValue("--theme") ?? process.env.DAWG_THEME),
218
+ reducedMotion:
219
+ args.has("--reduce-motion") || process.env.DAWG_REDUCE_MOTION === "1",
220
+ });
221
+ let syncState: SyncState = port.sync;
222
+ let windowCount = 1;
223
+ let announcedName = record.meta.name;
224
+ let namer = makeNamer();
225
+ /** Re-subscribes the TUI after /fork or /resume replaces `port`. */
226
+ let rebindPort: () => void = () => undefined;
227
+ /** The in-flight agent turn: Esc aborts it, Enter steers it. */
228
+ let agentTurn: { controller: AbortController; steering: string[] } | undefined;
229
+ let reportAgentActivity: (text: string) => void = () => undefined;
230
+ // Declared before `await runInteractive()` runs, or assigning it is a TDZ error.
231
+ let agentEventSink: (event: AgentEvent) => void = () => undefined;
232
+ /** Resolved lazily (and again after /login); `undefined` until first needed. */
233
+ let provider: Promise<ProviderSelection> | undefined;
234
+ let providerStamp = { fingerprint: "", at: 0, offline: false };
235
+ let providerName = "";
236
+ if (demo) {
237
+ if (score.notes.length === 0) score = seedDemo(score, requestedTrack);
238
+ if (exportPath)
239
+ await writeFile(resolve(exportPath), encodeLoop(score), "utf8");
240
+ renderOnce(
241
+ score,
242
+ clock.beatAt(),
243
+ "demo · press space to play",
244
+ "add C4 at 0 for 1",
245
+ 0,
246
+ );
247
+ process.exit(0);
248
+ }
249
+
250
+ if (exportPath) {
251
+ await writeFile(resolve(exportPath), encodeLoop(score), "utf8");
252
+ if (!stdin.isTTY) process.exit(0);
253
+ }
254
+
255
+ await runInteractive();
256
+
257
+ function optionValue(name: string): string | undefined {
258
+ const index = process.argv.indexOf(name);
259
+ const value = index >= 0 ? process.argv[index + 1] : undefined;
260
+ return value && !value.startsWith("--") ? value : undefined;
261
+ }
262
+
263
+ function seedDemo(value: TrackScore, trackId: string): TrackScore {
264
+ const instrument = value.tracks.find(
265
+ (track) => track.id === trackId,
266
+ )?.instrument;
267
+ if (isDrumInstrument(instrument)) return seedDemoDrums(value, trackId);
268
+ return addNote(
269
+ addNote(
270
+ addNote(value, {
271
+ id: "demo-1",
272
+ trackId,
273
+ startTick: 0,
274
+ durationTicks: 240,
275
+ pitch: 60,
276
+ velocity: 0.9,
277
+ }),
278
+ {
279
+ id: "demo-2",
280
+ trackId,
281
+ startTick: 480,
282
+ durationTicks: 480,
283
+ pitch: 64,
284
+ velocity: 0.75,
285
+ },
286
+ ),
287
+ {
288
+ id: "demo-3",
289
+ trackId,
290
+ startTick: 960,
291
+ durationTicks: 240,
292
+ pitch: 67,
293
+ velocity: 0.85,
294
+ },
295
+ );
296
+ }
297
+
298
+ /** A one-bar kick/snare/hat groove for a drum track in demo mode. */
299
+ function seedDemoDrums(value: TrackScore, trackId: string): TrackScore {
300
+ const hits: Array<["kick" | "snare" | "hat", number, number]> = [
301
+ ["kick", 0, 0.95],
302
+ ["hat", 0.5, 0.6],
303
+ ["snare", 1, 0.85],
304
+ ["hat", 1.5, 0.6],
305
+ ["kick", 2, 0.9],
306
+ ["hat", 2.5, 0.6],
307
+ ["snare", 3, 0.85],
308
+ ["hat", 3.5, 0.6],
309
+ ];
310
+ return hits.reduce(
311
+ (next, [voice, beat, velocity], index) =>
312
+ addNote(next, {
313
+ id: `demo-${index + 1}`,
314
+ trackId,
315
+ startTick: Math.round(beat * next.ticksPerBeat),
316
+ durationTicks: Math.round(next.ticksPerBeat / 4),
317
+ pitch: drumVoicePitch(voice),
318
+ velocity,
319
+ }),
320
+ value,
321
+ );
322
+ }
323
+
324
+ function snapshot(
325
+ value: TrackScore,
326
+ beat: number,
327
+ activity?: string,
328
+ ): TrackScoreSnapshot {
329
+ const notes = value.notes
330
+ .filter((note) => note.trackId === requestedTrack)
331
+ .map((note) => ({
332
+ id: note.id,
333
+ startBeat: note.startTick / value.ticksPerBeat,
334
+ durationBeats: note.durationTicks / value.ticksPerBeat,
335
+ pitch: note.pitch,
336
+ velocity: note.velocity,
337
+ muted: value.tracks.find((track) => track.id === requestedTrack)?.muted,
338
+ }));
339
+ return {
340
+ notes,
341
+ trackName:
342
+ value.tracks.find((track) => track.id === requestedTrack)?.name ??
343
+ requestedTrack,
344
+ trackId: requestedTrack,
345
+ sessionId: record.sessionId,
346
+ revision: record.revision,
347
+ bpm: value.tempoBpm,
348
+ key: value.key ?? undefined,
349
+ loopBeats: value.bars * value.beatsPerBar,
350
+ beatsPerBar: value.beatsPerBar,
351
+ laneCount: 24,
352
+ currentBeat: beat,
353
+ playing: clock.playing,
354
+ activity,
355
+ ...drumSnapshotFields(
356
+ value.tracks.find((track) => track.id === requestedTrack)?.instrument,
357
+ notes,
358
+ ),
359
+ layers:
360
+ tui.highwayView === "all"
361
+ ? highwayLayers(
362
+ value.tracks,
363
+ value.notes,
364
+ value.ticksPerBeat,
365
+ requestedTrack,
366
+ )
367
+ : undefined,
368
+ };
369
+ }
370
+
371
+ function renderOnce(
372
+ value: TrackScore,
373
+ transportBeat: number,
374
+ activity?: string,
375
+ draft = "",
376
+ nowMs = Date.now(),
377
+ ): void {
378
+ if (activity) tui.activity.pushCard(activity, { tone: "info" });
379
+ if (draft) tui.input({ type: "paste", text: draft });
380
+ const frame = composeFrame(
381
+ appView(value, transportBeat),
382
+ tui.ui,
383
+ { width: Math.max(24, stdout.columns ?? 80), height: 20 },
384
+ nowMs,
385
+ );
386
+ stdout.write(`${encodeBuffer(frame.buffer, tui.capabilities)}\n`);
387
+ }
388
+
389
+ function appView(value: TrackScore, beat: number): AppView {
390
+ return {
391
+ score: snapshot(value, beat),
392
+ beat,
393
+ // `opus-5.5 · gateway`, `claude/sonnet · xcb`; hidden when offline.
394
+ model:
395
+ providerName && providerName !== "offline" ? providerName : undefined,
396
+ sync: syncState,
397
+ sessionName: record.meta.name,
398
+ windows: windowCount,
399
+ };
400
+ }
401
+
402
+ /** Show a command receipt in the activity strip with a tone and undo hint. */
403
+ function receipt(message: string, baseRevision?: number): void {
404
+ const tone = receiptTone(message);
405
+ const changed =
406
+ baseRevision !== undefined && record.revision !== baseRevision;
407
+ if (tone === "error") tui.activity.pushError(message);
408
+ else
409
+ tui.activity.pushCard(message, {
410
+ tone,
411
+ baseRevision: changed ? baseRevision : undefined,
412
+ resultRevision: changed ? record.revision : undefined,
413
+ hint: changed
414
+ ? message.startsWith("undid")
415
+ ? "^y redo"
416
+ : "^z undo"
417
+ : undefined,
418
+ trackId: requestedTrack,
419
+ });
420
+ }
421
+
422
+ function truncateForCard(value: string): string {
423
+ const line = value.replace(/\s+/g, " ").trim();
424
+ return line.length > 32 ? `${line.slice(0, 31)}…` : line;
425
+ }
426
+
427
+ async function runInteractive(): Promise<void> {
428
+ stdin.setRawMode?.(true);
429
+ stdin.resume();
430
+ // Alternate screen, hidden cursor, bracketed paste.
431
+ stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
432
+ const inputDecoder = new TerminalInputDecoder();
433
+ const queuedPrompts: string[] = [];
434
+ let processingQueue = false;
435
+ const runPrompt = async (text: string): Promise<void> => {
436
+ const ui = tui.command(text);
437
+ if (ui !== undefined) {
438
+ tui.activity.pushCard(ui, { tone: "info" });
439
+ return;
440
+ }
441
+ tui.activity.pushRequest(text);
442
+ const base = record.revision;
443
+ const baseSession = record.sessionId;
444
+ try {
445
+ const message = await submit(text);
446
+ // Agent turns report through agentEventSink; skip a duplicate receipt.
447
+ if (!agentReported) receipt(message, base);
448
+ // Naming runs later on a timer; it never delays the next prompt.
449
+ if (record.sessionId === baseSession)
450
+ namer.noteTurn({
451
+ score,
452
+ prompt: text,
453
+ accepted: record.revision !== base,
454
+ });
455
+ } catch (error) {
456
+ tui.activity.pushError(
457
+ `error · ${error instanceof Error ? error.message : String(error)}`,
458
+ );
459
+ } finally {
460
+ agentReported = false;
461
+ }
462
+ };
463
+ let agentReported = false;
464
+ const drainQueue = async (): Promise<void> => {
465
+ if (processingQueue) return;
466
+ processingQueue = true;
467
+ try {
468
+ while (queuedPrompts.length > 0) {
469
+ const nextPrompt = queuedPrompts.shift()!;
470
+ tui.activity.setQueueDepth(queuedPrompts.length);
471
+ await runPrompt(nextPrompt);
472
+ tick(true);
473
+ }
474
+ } finally {
475
+ processingQueue = false;
476
+ tui.activity.setQueueDepth(queuedPrompts.length);
477
+ }
478
+ };
479
+ const tick = (force = false) => {
480
+ tui.render(appView(score, clock.beatAt()), { force });
481
+ };
482
+ reportAgentActivity = () => {
483
+ tui.activity.applyAgentEvent({ type: "start", model: providerName });
484
+ };
485
+ agentEventSink = (event) => {
486
+ tui.activity.applyAgentEvent(event);
487
+ if (event.type === "done" || event.type === "error") agentReported = true;
488
+ };
489
+ // ~30 fps cap; the differential writer only emits changed rows.
490
+ const timer = setInterval(tick, tui.frameIntervalMs);
491
+ const onResize = () => {
492
+ tui.invalidate();
493
+ tick(true);
494
+ };
495
+ stdout.on("resize", onResize);
496
+ let applying: Promise<void> = Promise.resolve();
497
+ const applyLatest = (latest: typeof record): Promise<void> =>
498
+ (applying = applying.then(() => applyRecord(latest)));
499
+ const applyRecord = async (latest: typeof record): Promise<void> => {
500
+ try {
501
+ if (latest.revision > record.revision) {
502
+ const previousRevision = record.revision;
503
+ record = latest;
504
+ score = scoreFromJSON(record.composition);
505
+ clock.setTempo(score.tempoBpm);
506
+ if (clock.playing) void audio.play(score);
507
+ // Connected windows follow dawgd's transport frames instead.
508
+ const replay =
509
+ port.mode === "file" ? latest.events.slice(previousRevision) : [];
510
+ for (const event of replay) {
511
+ if (
512
+ event.kind !== "transport" ||
513
+ typeof event.payload !== "object" ||
514
+ event.payload === null
515
+ )
516
+ continue;
517
+ const payload = event.payload as {
518
+ action?: string;
519
+ playing?: boolean;
520
+ beat?: number;
521
+ };
522
+ if (
523
+ typeof payload.playing === "boolean" &&
524
+ typeof payload.beat === "number" &&
525
+ Number.isFinite(payload.beat)
526
+ ) {
527
+ clock.sync(payload.beat, payload.playing, Date.parse(event.at));
528
+ if (payload.playing) void audio.play(score, clock.beatAt());
529
+ else audio.stop();
530
+ } else if (typeof payload.playing === "boolean")
531
+ await setTransport(payload.playing ? "play" : "pause");
532
+ else if (payload.action === "play") await setTransport("play");
533
+ else if (payload.action === "pause") await setTransport("pause");
534
+ else if (payload.action === "toggle") await setTransport("toggle");
535
+ }
536
+ tui.activity.pushCard("synced from another window", {
537
+ tone: "info",
538
+ baseRevision: previousRevision,
539
+ resultRevision: record.revision,
540
+ });
541
+ }
542
+ } catch {
543
+ // An invalid composition is skipped; the next update retries.
544
+ }
545
+ };
546
+ const onUpdate: Parameters<typeof port.subscribe>[0] = (update) => {
547
+ if (update.type === "record") {
548
+ void applyLatest(update.record);
549
+ adoptMeta(update.record.meta);
550
+ } else if (update.type === "meta") adoptMeta(update.meta);
551
+ else if (update.type === "presence") windowCount = update.clients.length;
552
+ else if (update.type === "sync") syncState = update.sync;
553
+ else if (update.type === "transport") {
554
+ // Every window renders the same hit line from dawgd's timestamp.
555
+ const { playing, beat, bpm, atMs } = update.transport;
556
+ clock.setTempo(bpm);
557
+ clock.sync(beat, playing, atMs, monotonicEpochMs());
558
+ } else if (update.type === "status") {
559
+ syncState = port.sync;
560
+ tui.activity.pushCard(update.message, {
561
+ tone: syncState === "offline" ? "warning" : "info",
562
+ });
563
+ }
564
+ };
565
+ let unsubscribe = port.subscribe(onUpdate);
566
+ const refreshPresence = () =>
567
+ void port
568
+ .presence()
569
+ .then((clients) => {
570
+ windowCount = Math.max(1, clients.length);
571
+ })
572
+ .catch(() => undefined);
573
+ // File-lock windows have no presence push; poll the heartbeat files.
574
+ const presenceTimer = setInterval(() => {
575
+ if (port.mode === "file") refreshPresence();
576
+ }, 2_000);
577
+ refreshPresence();
578
+ rebindPort = () => {
579
+ unsubscribe();
580
+ syncState = port.sync;
581
+ unsubscribe = port.subscribe(onUpdate);
582
+ refreshPresence();
583
+ };
584
+ if (attachNotice)
585
+ tui.activity.pushCard(attachNotice, {
586
+ tone: "info",
587
+ trackId: requestedTrack,
588
+ hint: draftTrack ? "first edit creates it" : undefined,
589
+ });
590
+ if (port.status !== "file session")
591
+ tui.activity.pushCard(port.status, { tone: "info" });
592
+ void currentProvider().then(() => tick(true));
593
+ tick(true);
594
+ try {
595
+ let exiting = false;
596
+ for await (const chunk of stdin) {
597
+ const text = String(chunk);
598
+ // A read that is exactly ESC is the Esc key, not the start of a sequence.
599
+ const values = [
600
+ ...inputDecoder.push(text),
601
+ ...(text === "\u001b" ? inputDecoder.flush() : []),
602
+ ];
603
+ for (const value of values) {
604
+ // The prompt is always focused, so ordinary `q` must remain typeable in
605
+ // requests (for example, "quiet hi-hat"). Ctrl-C is the unambiguous
606
+ // shell exit key; Ctrl-Q is reserved for prompt mode switching.
607
+ // Keep the empty-prompt space shortcut for transport, while allowing
608
+ // ordinary spaces once a request is being composed.
609
+ if (value === " " && prompt.value.length === 0) {
610
+ await setTransport("toggle");
611
+ try {
612
+ record = await port.append(
613
+ record,
614
+ {
615
+ kind: "transport",
616
+ payload: {
617
+ action: "toggle",
618
+ playing: clock.playing,
619
+ beat: clock.beatAt(),
620
+ },
621
+ },
622
+ score.toJSON(),
623
+ );
624
+ } catch (error) {
625
+ if (error instanceof SessionConflictError)
626
+ tui.activity.pushCard("transport changed in another window", {
627
+ tone: "warning",
628
+ });
629
+ else throw error;
630
+ }
631
+ } else {
632
+ const input = tui.input(value);
633
+ const action = input.type === "action" ? input.action : undefined;
634
+ if (input.type === "ui") {
635
+ if (input.command === "quit") exiting = true;
636
+ else if (
637
+ (input.command === "undo" || input.command === "redo") &&
638
+ !agentTurn
639
+ ) {
640
+ const base = record.revision;
641
+ receipt(await stepHistory(input.command), base);
642
+ }
643
+ } else if (input.type === "pick") {
644
+ // Picker choices run as the command they stand for.
645
+ queuedPrompts.unshift(
646
+ input.picker === "resume"
647
+ ? `/resume ${input.value}`
648
+ : input.value,
649
+ );
650
+ void drainQueue();
651
+ } else if (action?.kind === "exit") exiting = true;
652
+ else if (action?.kind === "cancel" && agentTurn) {
653
+ agentTurn.controller.abort();
654
+ tui.activity.setSpinner("cancelling");
655
+ } else if (action?.kind === "submit" && action.value && agentTurn) {
656
+ // Enter during a turn steers it; Alt-Enter still queues a follow-up.
657
+ agentTurn.steering.push(action.value);
658
+ tui.activity.pushCard(
659
+ `steering · ${truncateForCard(action.value)}`,
660
+ { tone: "agent", hint: "next step" },
661
+ );
662
+ } else if (action?.kind === "submit" && action.value) {
663
+ queuedPrompts.unshift(action.value);
664
+ // Do not await: the input loop must stay live so Esc can cancel.
665
+ void drainQueue();
666
+ } else if (action?.kind === "queue" && action.value) {
667
+ queuedPrompts.push(action.value);
668
+ tui.activity.setQueueDepth(queuedPrompts.length);
669
+ tui.activity.pushCard(`queued · ${truncateForCard(action.value)}`, {
670
+ tone: "info",
671
+ });
672
+ void drainQueue();
673
+ }
674
+ }
675
+ if (exiting) break;
676
+ tick(true);
677
+ }
678
+ if (exiting) break;
679
+ }
680
+ } finally {
681
+ clearInterval(timer);
682
+ clearInterval(presenceTimer);
683
+ namer.dispose();
684
+ stdout.off("resize", onResize);
685
+ unsubscribe();
686
+ audio.stop();
687
+ await port.close();
688
+ stdin.setRawMode?.(false);
689
+ stdin.pause();
690
+ stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
691
+ }
692
+ }
693
+
694
+ async function submit(prompt: string): Promise<string> {
695
+ const command = prompt.trim();
696
+ if (/^\/?help$|^\/?\?$/.test(command.toLowerCase()))
697
+ return "commands · play pause tempo <bpm> instrument <name> volume <0..1> pan <-1..1> automate volume|pan|filter|resonance|delay-feedback|delay-mix at <beat> <value> clear automation track <name> bars <count> extend <count> bars mute solo unsolo filter <hz> [res] delay <beats> [fb] [mix] reverb <mix> [size] hit <voice> at <beat> pattern <voice> <beats...>|every <step> clear <voice> clear undo redo export <file> import <file>";
698
+ if (/^\/?tracks?$/i.test(command))
699
+ return score.tracks
700
+ .map(
701
+ (track) =>
702
+ `${track.id}${track.muted ? " [muted]" : ""}${track.solo ? " [solo]" : ""} · ${track.instrument}`,
703
+ )
704
+ .join(" ");
705
+ if (/^\/status$/i.test(command))
706
+ return `status · ${record.meta.name} · rev ${record.revision} · ${compositionDigest(record.composition)} · ${port.mode}`;
707
+ if (/^\/?undo$/i.test(command)) return stepHistory("undo");
708
+ if (/^\/?redo$/i.test(command)) return stepHistory("redo");
709
+ const sessionReply = await sessionCommand(command);
710
+ if (sessionReply !== undefined) return sessionReply;
711
+ const music = parseMusicCommand(command);
712
+ if (music) {
713
+ await materializeDraft();
714
+ const result = applyMusicCommand(score, requestedTrack, music, () =>
715
+ randomUUID().slice(0, 12),
716
+ );
717
+ if (result.next && result.kind)
718
+ await commitScore(result.next, result.kind, result.payload);
719
+ return result.message;
720
+ }
721
+ const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
722
+ if (exportCommand) {
723
+ const path = resolve(exportCommand[1]!);
724
+ await writeFile(path, encodeLoop(score), "utf8");
725
+ return `exported · ${exportCommand[1]}`;
726
+ }
727
+ const importCommand = command.match(/^\/?import\s+([^\s]+)$/i);
728
+ if (importCommand) {
729
+ await materializeDraft();
730
+ const imported = decodeLoop(await readLoopFile(importCommand[1]!));
731
+ await commitScore(imported, "score.import", { path: importCommand[1] });
732
+ return `imported · ${importCommand[1]}`;
733
+ }
734
+ const modelCommand = prompt.trim().match(/^\/model\s+(opus-5\.5|sol-6\.1)$/i);
735
+ if (modelCommand) {
736
+ selectedModel = modelCommand[1]!.toLowerCase() as GatewayModel;
737
+ if (provider) void currentProvider();
738
+ return `model · ${selectedModel}`;
739
+ }
740
+ if (/^\/login\s+--xcb$/i.test(prompt.trim())) {
741
+ tui.activity.setSpinner("xcb accounts");
742
+ try {
743
+ const items = await xcbPickerItems();
744
+ if (items && items.length > 1) {
745
+ tui.openPicker({
746
+ id: "xcb",
747
+ title: "xcb account · model · ↑/↓ Enter · Esc",
748
+ items,
749
+ });
750
+ return `xcb · ${items.length} choices`;
751
+ }
752
+ } finally {
753
+ tui.activity.setSpinner(undefined);
754
+ }
755
+ // Zero or one choice: the plain flow saves it or prints guidance.
756
+ }
757
+ if (/^\/(login|logout|auth)\b/i.test(prompt.trim())) {
758
+ tui.activity.setSpinner(prompt.trim().split(/\s+/)[0]!.slice(1));
759
+ try {
760
+ const lines = await tuiAuthCommand(prompt.trim(), selectedModel);
761
+ provider = undefined;
762
+ await currentProvider();
763
+ for (const line of lines.slice(0, -1))
764
+ tui.activity.pushCard(line, { tone: authTone(line) });
765
+ return lines.at(-1) ?? "auth · done";
766
+ } finally {
767
+ tui.activity.setSpinner(undefined);
768
+ }
769
+ }
770
+ const parsed = parsePrompt(prompt);
771
+ if (!parsed) {
772
+ if (process.env.DAWG_AI === "0") return `unrecognized request: ${prompt}`;
773
+ await materializeDraft();
774
+ return runAgent(prompt);
775
+ }
776
+ if (parsed.type !== "transport") await materializeDraft();
777
+ if (parsed.type === "transport") {
778
+ await setTransport(parsed.action);
779
+ try {
780
+ record = await port.append(
781
+ record,
782
+ {
783
+ kind: "transport",
784
+ payload: {
785
+ action: parsed.action,
786
+ playing: clock.playing,
787
+ beat: clock.beatAt(),
788
+ },
789
+ },
790
+ score.toJSON(),
791
+ );
792
+ } catch (error) {
793
+ if (error instanceof SessionConflictError)
794
+ return "transport changed in another window";
795
+ throw error;
796
+ }
797
+ return parsed.action;
798
+ }
799
+ if (parsed.type === "set-tempo") {
800
+ const next = score.withTempo(parsed.tempoBpm);
801
+ await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
802
+ clock.setTempo?.(next.tempoBpm);
803
+ return `tempo · ${next.tempoBpm} BPM`;
804
+ }
805
+ if (parsed.type === "add-track") {
806
+ if (score.tracks.some((track) => track.id === parsed.trackId))
807
+ return `track exists · ${parsed.trackId}`;
808
+ const next = applyScoreOperation(score, {
809
+ type: "addTrack",
810
+ track: {
811
+ id: parsed.trackId,
812
+ name: parsed.trackId,
813
+ instrument: isDrumInstrument(parsed.trackId) ? "kit" : "sine",
814
+ },
815
+ });
816
+ await commitScore(next, "track.create", { trackId: parsed.trackId });
817
+ return `track created · ${parsed.trackId}`;
818
+ }
819
+ if (parsed.type === "set-bars" || parsed.type === "extend-bars") {
820
+ const bars =
821
+ parsed.type === "set-bars"
822
+ ? parsed.bars
823
+ : Math.min(SCORE_LIMITS.maxBars, score.bars + parsed.bars);
824
+ const next = applyScoreOperation(score, { type: "setBars", bars });
825
+ await commitScore(next, "score.bars", { bars });
826
+ return `bars · ${bars}`;
827
+ }
828
+ if (parsed.type === "track") {
829
+ const next = applyScoreOperation(score, {
830
+ type: "updateTrack",
831
+ trackId: requestedTrack,
832
+ patch: parsed.patch,
833
+ });
834
+ await commitScore(next, "score.track", {
835
+ trackId: requestedTrack,
836
+ patch: parsed.patch,
837
+ });
838
+ return `track · ${requestedTrack}`;
839
+ }
840
+ if (parsed.type === "automation") {
841
+ const points = parsed.points.map((point) => ({
842
+ tick: Math.max(0, Math.round(point.beat * score.ticksPerBeat)),
843
+ value: point.value,
844
+ }));
845
+ const track = score.tracks.find(
846
+ (candidate) => candidate.id === requestedTrack,
847
+ );
848
+ const current =
849
+ parsed.parameter === "pan"
850
+ ? (track?.panAutomation ?? [])
851
+ : (track?.volumeAutomation ?? []);
852
+ const merged =
853
+ points.length === 0
854
+ ? []
855
+ : Array.from(
856
+ new Map(
857
+ [...current, ...points].map((point) => [point.tick, point]),
858
+ ).values(),
859
+ ).sort((left, right) => left.tick - right.tick);
860
+ const next = applyScoreOperation(score, {
861
+ type: "setAutomation",
862
+ trackId: requestedTrack,
863
+ parameter: parsed.parameter,
864
+ points: merged,
865
+ });
866
+ await commitScore(next, "score.automation", {
867
+ trackId: requestedTrack,
868
+ parameter: parsed.parameter,
869
+ points: merged,
870
+ });
871
+ return `automation · ${parsed.parameter} ${merged.length} point${merged.length === 1 ? "" : "s"}`;
872
+ }
873
+ if (parsed.type === "clear-track") {
874
+ const next = applyScoreOperation(score, {
875
+ type: "clearTrack",
876
+ trackId: requestedTrack,
877
+ });
878
+ await commitScore(next, "score.clear", { trackId: requestedTrack });
879
+ return `cleared · ${requestedTrack}`;
880
+ }
881
+ if (parsed.type === "remove-note") {
882
+ const next = applyScoreOperation(score, {
883
+ type: "removeNote",
884
+ noteId: parsed.noteId,
885
+ });
886
+ await commitScore(next, "score.operation", {
887
+ operation: { type: "removeNote", noteId: parsed.noteId },
888
+ });
889
+ return `removed · ${parsed.noteId}`;
890
+ }
891
+ if (parsed.type === "update-note") {
892
+ const patch = {
893
+ ...(typeof parsed.patch.start === "number"
894
+ ? { startTick: Math.round(parsed.patch.start * score.ticksPerBeat) }
895
+ : {}),
896
+ ...(typeof parsed.patch.duration === "number"
897
+ ? {
898
+ durationTicks: Math.max(
899
+ 1,
900
+ Math.round(parsed.patch.duration * score.ticksPerBeat),
901
+ ),
902
+ }
903
+ : {}),
904
+ ...(typeof parsed.patch.pitch === "number"
905
+ ? { pitch: parsed.patch.pitch }
906
+ : {}),
907
+ ...(typeof parsed.patch.velocity === "number"
908
+ ? { velocity: parsed.patch.velocity }
909
+ : {}),
910
+ };
911
+ const next = applyScoreOperation(score, {
912
+ type: "updateNote",
913
+ noteId: parsed.noteId,
914
+ patch,
915
+ });
916
+ await commitScore(next, "score.note", {
917
+ operation: { type: "updateNote", noteId: parsed.noteId, patch },
918
+ });
919
+ return `updated · ${parsed.noteId}`;
920
+ }
921
+ if (parsed.type !== "add-note") return "queued";
922
+ for (let attempt = 0; attempt < 3; attempt += 1) {
923
+ const latest = attempt === 0 ? record : await port.load();
924
+ const latestScore = scoreFromJSON(latest.composition);
925
+ const note = {
926
+ id: `${requestedTrack}-${latest.revision + 1}-${randomUUID().slice(0, 6)}`,
927
+ trackId: requestedTrack,
928
+ startTick: Math.round(parsed.start * latestScore.ticksPerBeat),
929
+ durationTicks: Math.max(
930
+ 1,
931
+ Math.round(parsed.duration * latestScore.ticksPerBeat),
932
+ ),
933
+ pitch: parsed.pitch,
934
+ velocity: parsed.velocity,
935
+ };
936
+ const operation: ScoreOperation = { type: "addNote", note };
937
+ const next = applyScoreOperation(latestScore, operation);
938
+ try {
939
+ record = await port.append(
940
+ latest,
941
+ {
942
+ kind: "score.operation",
943
+ payload: { operation, before: latest.composition },
944
+ },
945
+ next.toJSON(),
946
+ );
947
+ score = next;
948
+ if (clock.playing) void audio.play(score);
949
+ return `added ${note.id}`;
950
+ } catch (error) {
951
+ if (!(error instanceof SessionConflictError)) throw error;
952
+ }
953
+ }
954
+ return "session busy; retry the note";
955
+ }
956
+
957
+ async function readLoopFile(path: string): Promise<string> {
958
+ const contents = await readFile(resolve(path));
959
+ if (contents.byteLength > 512 * 1024)
960
+ throw new Error("loop import exceeds 512 KiB");
961
+ return contents.toString("utf8");
962
+ }
963
+
964
+ /**
965
+ * Adds the focused track to the score when it is missing (an explicit
966
+ * `--track`, or a draft on its first edit), retrying past concurrent writes.
967
+ */
968
+ async function ensureFocusedTrack(): Promise<void> {
969
+ if (score.tracks.some((track) => track.id === requestedTrack)) return;
970
+ for (let attempt = 0; attempt < 3; attempt += 1) {
971
+ const latest = attempt === 0 ? record : await port.load();
972
+ const latestScore = scoreFromJSON(latest.composition);
973
+ if (latestScore.tracks.some((track) => track.id === requestedTrack)) {
974
+ record = latest;
975
+ score = latestScore;
976
+ return;
977
+ }
978
+ const next = withTrack(latestScore, requestedTrack);
979
+ try {
980
+ record = await port.append(
981
+ latest,
982
+ { kind: "track.attach", payload: { trackId: requestedTrack } },
983
+ next.toJSON(),
984
+ );
985
+ score = next;
986
+ return;
987
+ } catch (error) {
988
+ if (!(error instanceof SessionConflictError)) throw error;
989
+ }
990
+ }
991
+ }
992
+
993
+ /** A draft track becomes real on the window's first edit. */
994
+ async function materializeDraft(): Promise<void> {
995
+ if (!draftTrack) return;
996
+ await ensureFocusedTrack();
997
+ draftTrack = false;
998
+ }
999
+
1000
+ function adoptMeta(meta: typeof record.meta): void {
1001
+ if (meta.version < record.meta.version) return;
1002
+ record = { ...record, meta };
1003
+ if (meta.name !== announcedName) {
1004
+ announcedName = meta.name;
1005
+ tui.activity.pushCard(`session · ${meta.name}`, { tone: "info" });
1006
+ }
1007
+ }
1008
+
1009
+ function makeNamer(): AutoNamer {
1010
+ return new AutoNamer({
1011
+ target: namingTarget(
1012
+ process.cwd(),
1013
+ () => ({ port, record }),
1014
+ (meta) => adoptMeta(meta),
1015
+ ),
1016
+ // DAWG_AI=0 keeps naming local; an offline provider falls back too.
1017
+ generator:
1018
+ process.env.DAWG_AI === "0"
1019
+ ? undefined
1020
+ : providerNameGenerator(currentProvider),
1021
+ });
1022
+ }
1023
+
1024
+ /** `/sessions`, `/resume`, `/rename`, `/fork`; undefined when not one. */
1025
+ async function sessionCommand(command: string): Promise<string | undefined> {
1026
+ const match = command.match(/^\/(sessions|resume|rename|fork)(?:\s+(.*))?$/i);
1027
+ if (!match) return undefined;
1028
+ const verb = match[1]!.toLowerCase();
1029
+ const arg = (match[2] ?? "").trim();
1030
+ const workspace = process.cwd();
1031
+ if (verb === "rename") {
1032
+ if (!arg)
1033
+ return `session · ${record.meta.name} (${record.meta.nameSource}) · /rename <name> | --auto`;
1034
+ if (arg === "--auto") {
1035
+ const result = await port.updateMeta({
1036
+ nameSource: "auto",
1037
+ namedFingerprint: null,
1038
+ namedStructure: null,
1039
+ });
1040
+ adoptMeta(result.meta);
1041
+ namer.request(score);
1042
+ return "auto-naming on";
1043
+ }
1044
+ const name = normalizeSessionName(arg);
1045
+ // Unconditional: the latest user rename wins over any in-flight auto-name.
1046
+ const result = await port.updateMeta({ name, nameSource: "user" });
1047
+ announcedName = result.meta.name;
1048
+ adoptMeta(result.meta);
1049
+ return `renamed · ${result.meta.name}`;
1050
+ }
1051
+ const sessions = await listSessions(workspace);
1052
+ if (verb === "resume" && !arg) {
1053
+ const readable = sessions.filter((session) => !session.error).slice(0, 64);
1054
+ if (readable.length === 0) return "no sessions to resume";
1055
+ const current = readable.findIndex(
1056
+ (session) => session.sessionId === record.sessionId,
1057
+ );
1058
+ tui.openPicker({
1059
+ id: "resume",
1060
+ title: "resume session · ↑/↓ Enter · Esc",
1061
+ items: readable.map((session) => ({
1062
+ label: `${session.sessionId === record.sessionId ? "* " : " "}${formatSessionLine(session, sessions)}`,
1063
+ value: session.sessionId,
1064
+ })),
1065
+ index: Math.max(0, current),
1066
+ });
1067
+ return "resume · pick a session (or /resume <n|name|id>)";
1068
+ }
1069
+ if (verb === "sessions") {
1070
+ const lines = pickerLines(sessions, record.sessionId, (session) =>
1071
+ formatSessionLine(session, sessions),
1072
+ );
1073
+ for (const line of lines) tui.activity.pushCard(line, { tone: "info" });
1074
+ return `${lines.length} sessions`;
1075
+ }
1076
+ if (agentTurn) return "agent busy; finish or Esc first";
1077
+ if (verb === "fork") {
1078
+ const forked = await forkSession(workspace, record, arg || undefined);
1079
+ await switchSession(forked.sessionId);
1080
+ return `forked · ${forked.meta.name}`;
1081
+ }
1082
+ const readable = sessions.filter((session) => !session.error);
1083
+ const index = /^[1-9]$/.test(arg) ? Number(arg) - 1 : -1;
1084
+ const sessionId =
1085
+ readable[index]?.sessionId ??
1086
+ (await resolveSessionArg(workspace, arg, sessions));
1087
+ if (!sessions.some((session) => session.sessionId === sessionId))
1088
+ return `no session named "${arg}"`;
1089
+ if (sessionId === record.sessionId) return `already in ${record.meta.name}`;
1090
+ await switchSession(sessionId);
1091
+ return `resumed · ${record.meta.name} · ${requestedTrack}${draftTrack ? " (new)" : ""}`;
1092
+ }
1093
+
1094
+ /** Leaves the current session and attaches this window to `sessionId`. */
1095
+ async function switchSession(sessionId: string): Promise<void> {
1096
+ if (clock.playing) await setTransport("pause");
1097
+ namer.dispose();
1098
+ await port.close();
1099
+ const next = await ensureSession(initial.toJSON(), {
1100
+ sessionId,
1101
+ setCurrent: true,
1102
+ });
1103
+ port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
1104
+ paths: next.paths,
1105
+ sessionId,
1106
+ label: "window",
1107
+ focusedTrackId: null,
1108
+ });
1109
+ audio = port.player;
1110
+ record = port.mode === "daemon" ? await port.load() : next.record;
1111
+ score = scoreFromJSON(record.composition);
1112
+ clock.setTempo(score.tempoBpm);
1113
+ const attached = await attachTrack(port, score, undefined);
1114
+ requestedTrack = attached.trackId;
1115
+ draftTrack = attached.draft;
1116
+ announcedName = record.meta.name;
1117
+ namer = makeNamer();
1118
+ rebindPort();
1119
+ if (attached.draft)
1120
+ tui.activity.pushCard(ALL_TRACKS_OPEN_HINT, {
1121
+ tone: "info",
1122
+ trackId: requestedTrack,
1123
+ });
1124
+ }
1125
+
1126
+ async function commitScore(
1127
+ next: TrackScore,
1128
+ kind: string,
1129
+ payload: Record<string, unknown> = {},
1130
+ ): Promise<void> {
1131
+ if (next === score) return;
1132
+ record = await port.append(
1133
+ record,
1134
+ { kind, payload: { ...payload, before: record.composition } },
1135
+ next.toJSON(),
1136
+ );
1137
+ score = next;
1138
+ if (clock.playing) void audio.play(score);
1139
+ }
1140
+
1141
+ async function stepHistory(direction: "undo" | "redo"): Promise<string> {
1142
+ const latest = await port.load();
1143
+ // A fork's undo continues into its parent's history past the fork point.
1144
+ const target = historyTarget(
1145
+ await historyEvents(process.cwd(), latest),
1146
+ direction,
1147
+ );
1148
+ if (!target) return `nothing to ${direction}`;
1149
+ try {
1150
+ const restored = scoreFromJSON(target.composition);
1151
+ record = await port.append(
1152
+ latest,
1153
+ {
1154
+ kind: direction === "undo" ? UNDO_KIND : REDO_KIND,
1155
+ payload: {
1156
+ [direction === "undo" ? "undoneRevision" : "redoneRevision"]:
1157
+ target.revision,
1158
+ before: latest.composition,
1159
+ },
1160
+ },
1161
+ restored.toJSON(),
1162
+ );
1163
+ score = restored;
1164
+ if (clock.playing) void audio.play(score);
1165
+ return `${direction === "undo" ? "undid" : "redid"} · rev ${target.revision}`;
1166
+ } catch (error) {
1167
+ if (error instanceof SessionConflictError)
1168
+ return `session changed; retry ${direction}`;
1169
+ return `${direction} error · ${error instanceof Error ? error.message : String(error)}`;
1170
+ }
1171
+ }
1172
+
1173
+ async function setTransport(
1174
+ action: "play" | "pause" | "toggle",
1175
+ ): Promise<void> {
1176
+ if (port.mode === "daemon") {
1177
+ // dawgd owns the only transport; its broadcast updates `clock`.
1178
+ await port.transport(action);
1179
+ return;
1180
+ }
1181
+ if (action === "play") {
1182
+ clock.play();
1183
+ await audio.play(score, clock.beatAt());
1184
+ } else if (action === "pause") {
1185
+ clock.pause();
1186
+ audio.stop();
1187
+ } else if (clock.playing) {
1188
+ clock.pause();
1189
+ audio.stop();
1190
+ } else {
1191
+ clock.play();
1192
+ await audio.play(score, clock.beatAt());
1193
+ }
1194
+ }
1195
+
1196
+ /**
1197
+ * Run one streaming tool-calling turn. Each validated tool call commits its
1198
+ * own revision through `agentHost`, so cancelling keeps every accepted change
1199
+ * and never leaves a half-applied call.
1200
+ */
1201
+ async function runAgent(text: string): Promise<string> {
1202
+ if (agentTurn) return "agent busy";
1203
+ const selection = await currentProvider();
1204
+ if (selection.kind === "offline")
1205
+ return `unrecognized request · ${selection.reason}`;
1206
+ const turn = { controller: new AbortController(), steering: [] as string[] };
1207
+ agentTurn = turn;
1208
+ reportAgentActivity(`${providerName} · thinking…`);
1209
+ // xcb admits a pending account on its first call, which takes longer.
1210
+ let admitting = selection.kind === "xcb" && selection.admissionPending;
1211
+ if (admitting) tui.activity.setSpinner("admitting account…");
1212
+ try {
1213
+ const result = await runProviderTurn({
1214
+ selection,
1215
+ prompt: text,
1216
+ model: selectedModel,
1217
+ host: agentHost(turn),
1218
+ signal: turn.controller.signal,
1219
+ onEvent: (event) => {
1220
+ if (admitting && event.type === "step" && event.step <= 1) return;
1221
+ if (admitting) {
1222
+ admitting = false;
1223
+ provider = undefined; // Re-read capabilities: now admitted.
1224
+ }
1225
+ agentEventSink(event);
1226
+ },
1227
+ });
1228
+ return describeAgentEvent(result) ?? "agent finished";
1229
+ } finally {
1230
+ if (agentTurn === turn) agentTurn = undefined;
1231
+ }
1232
+ }
1233
+
1234
+ function authTone(line: string): "success" | "warning" | "info" {
1235
+ if (line.startsWith("✓")) return "success";
1236
+ if (/^(✗|\?)|not |no |could not|failed/i.test(line)) return "warning";
1237
+ return "info";
1238
+ }
1239
+
1240
+ /**
1241
+ * The cached provider, re-resolved when `~/.config/dawg` config or
1242
+ * credentials change (two stats per call) and, while offline, at most every
1243
+ * 30 s so an account admitted elsewhere is picked up without a restart.
1244
+ */
1245
+ function currentProvider(): Promise<ProviderSelection> {
1246
+ const fingerprint = providerFingerprint();
1247
+ const stale =
1248
+ fingerprint !== providerStamp.fingerprint ||
1249
+ (providerStamp.offline && Date.now() - providerStamp.at > 30_000);
1250
+ if (provider && stale) provider = undefined;
1251
+ if (!provider) {
1252
+ providerStamp = { fingerprint, at: Date.now(), offline: false };
1253
+ provider = selectProvider().catch((): ProviderSelection => ({
1254
+ kind: "offline",
1255
+ choice: "auto",
1256
+ reason: "provider unavailable; run `dawg login`",
1257
+ }));
1258
+ }
1259
+ return provider.then((selection) => {
1260
+ providerName = providerLabel(selection, selectedModel);
1261
+ providerStamp.offline = selection.kind === "offline";
1262
+ return selection;
1263
+ });
1264
+ }
1265
+
1266
+ function agentHost(turn: { steering: string[] }): AgentHost {
1267
+ return {
1268
+ snapshot: () => ({
1269
+ score,
1270
+ revision: record.revision,
1271
+ focusedTrackId: requestedTrack,
1272
+ recentOperations: record.events.slice(-8).map((event) => {
1273
+ const payload = event.payload as { summary?: unknown } | null;
1274
+ return typeof payload?.summary === "string"
1275
+ ? `${event.kind}: ${payload.summary}`
1276
+ : event.kind;
1277
+ }),
1278
+ }),
1279
+ async commit(change) {
1280
+ // dawgd rebases operation intents onto newer revisions when nothing
1281
+ // they touch changed; the file port keeps the strict base check.
1282
+ if (port.mode !== "daemon" && change.baseRevision !== record.revision)
1283
+ throw new StaleRevisionError(change.baseRevision, record.revision);
1284
+ try {
1285
+ record = await port.appendOperations(
1286
+ { ...record, revision: change.baseRevision },
1287
+ {
1288
+ kind: "agent.tool",
1289
+ payload: {
1290
+ tool: change.toolName,
1291
+ callId: change.callId,
1292
+ summary: change.summary,
1293
+ operations: change.operations,
1294
+ before: record.composition,
1295
+ },
1296
+ },
1297
+ change.operations,
1298
+ change.next.toJSON(),
1299
+ );
1300
+ // A rebased commit lands on a newer score than `change.next`.
1301
+ score = scoreFromJSON(record.composition);
1302
+ if (clock.playing) void audio.play(score);
1303
+ } catch (error) {
1304
+ if (error instanceof SessionConflictError)
1305
+ throw new StaleRevisionError(
1306
+ change.baseRevision,
1307
+ record.revision + 1,
1308
+ );
1309
+ throw error;
1310
+ }
1311
+ if (change.operations.some((operation) => operation.type === "setTempo"))
1312
+ clock.setTempo(score.tempoBpm);
1313
+ return { revision: record.revision };
1314
+ },
1315
+ async transport(action) {
1316
+ await setTransport(action);
1317
+ try {
1318
+ record = await port.append(
1319
+ record,
1320
+ {
1321
+ kind: "transport",
1322
+ payload: { action, playing: clock.playing, beat: clock.beatAt() },
1323
+ },
1324
+ score.toJSON(),
1325
+ );
1326
+ } catch (error) {
1327
+ if (!(error instanceof SessionConflictError)) throw error;
1328
+ }
1329
+ },
1330
+ takeSteering: () => turn.steering.splice(0),
1331
+ };
1332
+ }