@hraness/dawg 0.2.0 → 0.3.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 (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
package/src/main.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { randomUUID } from "node:crypto";
3
- import { readFile, writeFile } from "node:fs/promises";
4
- import { resolve } from "node:path";
3
+ import { readFile, stat, writeFile } from "node:fs/promises";
4
+ import { join, relative, resolve } from "node:path";
5
5
  import { stdin, stdout } from "node:process";
6
6
  import {
7
7
  ensureSession,
@@ -30,8 +30,26 @@ import { AutoNamer, providerNameGenerator } from "./session/naming.ts";
30
30
  import { normalizeSessionName } from "./session/meta.ts";
31
31
  import { parsePrompt } from "./agent/ops.ts";
32
32
  import { applyMusicCommand, parseMusicCommand } from "./commands/music.ts";
33
+ import { applyEditCommand, parseEditCommand } from "./commands/edit.ts";
34
+ import { helpLines, helpText, usageHint } from "./commands/help.ts";
33
35
  import { historyTarget, REDO_KIND, UNDO_KIND } from "./commands/history.ts";
34
- import { drumSnapshotFields } from "../tui/drums.ts";
36
+ import {
37
+ SamplePlacementError,
38
+ addSampleVoice,
39
+ freeVoiceName,
40
+ listSampleVoices,
41
+ parseSampleCommand,
42
+ placeSampleFile,
43
+ samplerTarget,
44
+ voiceNameFrom,
45
+ } from "./commands/sample.ts";
46
+ import {
47
+ SampleLibrary,
48
+ hasSamplerTracks,
49
+ type SampleBank,
50
+ type SampleProblem,
51
+ } from "./audio/samples.ts";
52
+ import { drumSnapshotFields, samplerSnapshotFields } from "../tui/drums.ts";
35
53
  import { highwayLayers } from "../tui/layers.ts";
36
54
  import { drumVoicePitch, isDrumInstrument } from "../core/drums.ts";
37
55
  import {
@@ -40,17 +58,41 @@ import {
40
58
  type AgentEvent,
41
59
  type AgentHost,
42
60
  } from "./agent/agent.ts";
43
- import { type GatewayModel } from "./agent/gateway.ts";
44
61
  import {
62
+ isApiSelection,
45
63
  providerFingerprint,
46
64
  providerLabel,
47
65
  runProviderTurn,
48
66
  selectProvider,
49
67
  type ProviderSelection,
50
68
  } from "./agent/provider.ts";
51
- import { runAuthCommand } from "./auth/cli.ts";
52
- import { tuiAuthCommand, xcbPickerItems } from "./auth/tui.ts";
69
+ import {
70
+ MODEL_CATALOG,
71
+ priceForModel,
72
+ resolveModelChoice,
73
+ } from "./agent/models.ts";
74
+ import {
75
+ SpendMeter,
76
+ spendLine as formatSpendLine,
77
+ webHostFor,
78
+ } from "./agent/usage.ts";
79
+ import { configDir } from "./auth/credentials.ts";
80
+ import { runAuthCommand, runFirstRunLogin, runTuiLogin } from "./auth/cli.ts";
81
+ import {
82
+ modelPickerItems,
83
+ tuiAuthCommand,
84
+ tuiLoginArgs,
85
+ tuiSetModel,
86
+ } from "./auth/tui.ts";
53
87
  import { TransportClock } from "./audio/clock.ts";
88
+ import { AudioEngine } from "./audio/engine.ts";
89
+ import {
90
+ DEFAULT_GRID,
91
+ GRIDS,
92
+ PlaySession,
93
+ type LiveEngine,
94
+ } from "./tui/play-session.ts";
95
+ import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
54
96
  import {
55
97
  addNote,
56
98
  applyScoreOperation,
@@ -69,12 +111,46 @@ import {
69
111
  TuiApp,
70
112
  type AppView,
71
113
  type SyncState,
114
+ type TypesIndicator,
72
115
  } from "../tui/app.ts";
73
- import { receiptTone } from "../tui/activity.ts";
116
+ import { fail, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
117
+ import { systemRunner } from "./auth/runner.ts";
118
+ import type { MediaServices } from "./media/types.ts";
74
119
  import { encodeBuffer } from "../tui/screen.ts";
75
120
  import { parseThemeName } from "../tui/theme.ts";
121
+ import { formatDiagnostic } from "../core/sdk/eval.ts";
122
+ import { isProject } from "./project/init.ts";
123
+ import { typecheckProject } from "./project/typecheck.ts";
124
+ import {
125
+ startProjectSync,
126
+ type ProjectSync,
127
+ type SyncHost,
128
+ } from "./project/sync.ts";
76
129
 
77
130
  const ESC = "\u001b[";
131
+ /** `/sessions` rows shown in the overlay. */
132
+ const MAX_LISTED_SESSIONS = 64;
133
+
134
+ const SUBCOMMANDS = [
135
+ "login",
136
+ "logout",
137
+ "auth",
138
+ "sessions",
139
+ "render",
140
+ "init",
141
+ "check",
142
+ ];
143
+ const VALUE_FLAGS = ["--session", "--track", "--import", "--export", "--theme"];
144
+ const FLAGS = [
145
+ ...VALUE_FLAGS,
146
+ "--new",
147
+ "--demo",
148
+ "--help",
149
+ "-h",
150
+ "--version",
151
+ "-v",
152
+ "--reduce-motion",
153
+ ];
78
154
  const HELP_TEXT = `dawg · local-first terminal music workstation
79
155
 
80
156
  Usage:
@@ -82,6 +158,10 @@ Usage:
82
158
  dawg --import <file> --export <file>
83
159
  dawg sessions
84
160
  dawg render <out.wav> [--session <name|id>] [--import <file>]
161
+ dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
162
+ dawg check typecheck + evaluate the project; exit 1 on problems
163
+ dawg media doctor|download|stems|analyze|notes|sample|lyrics … (dawg media --help)
164
+ dawg --version
85
165
 
86
166
  Usage flags:
87
167
  --reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
@@ -92,24 +172,17 @@ Prompt:
92
172
  Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc cancel/close · Ctrl-C exit
93
173
  Space on an empty prompt toggles playback
94
174
 
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]
175
+ Commands (bare music words; app commands take a slash):
176
+ ${helpText()}
107
177
 
108
- Auth:
109
- dawg login [--gateway|--key|--xcb] [--budget <dollars>]
110
- dawg logout · dawg auth status [--check]
178
+ Sign in (the choice is saved and reused until you log out):
179
+ dawg login find existing setups and pick a provider
180
+ dawg login gateway|openrouter|codex|claude
181
+ dawg model [alias] pick a model, with the estimated cost per prompt
182
+ dawg logout [provider] · dawg auth status [--check]
111
183
  Unrecognized requests go to the agent once a provider is configured
112
- (DAWG_PROVIDER=gateway|xcb|auto; DAWG_AI=0 disables the agent).`;
184
+ (DAWG_PROVIDER=gateway|openrouter|codex|claude|auto, DAWG_MODEL=<alias>;
185
+ DAWG_AI=0 disables the agent).`;
113
186
  const args = new Set(process.argv.slice(2));
114
187
  const requestedSession = optionValue("--session");
115
188
  const explicitTrack = optionValue("--track");
@@ -118,16 +191,46 @@ let requestedTrack = explicitTrack ?? "main";
118
191
  const initialInstrument = isDrumInstrument(requestedTrack) ? "kit" : "sine";
119
192
  const importPath = optionValue("--import");
120
193
  const exportPath = optionValue("--export");
121
- if (["login", "logout", "auth"].includes(process.argv[2] ?? ""))
194
+ if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
122
195
  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);
196
+ {
197
+ // An unknown DAWG_MODEL is an error, never a silent fallback.
198
+ const fromEnv = process.env.DAWG_MODEL?.trim();
199
+ if (fromEnv && !resolveModelChoice("gateway", fromEnv)) {
200
+ process.stderr.write(
201
+ `dawg: unknown DAWG_MODEL "${fromEnv.slice(0, 40)}"; use one of ${MODEL_CATALOG.map((row) => row.alias).join(", ")} or a vendor/model ID\n`,
202
+ );
203
+ process.exit(2);
204
+ }
126
205
  }
127
206
  if (process.argv[2] === "sessions") {
128
- await printSessions(process.cwd(), stdout);
207
+ if (args.has("--help") || args.has("-h"))
208
+ stdout.write(
209
+ "usage: dawg sessions · lists this workspace's sessions, newest first (* marks the current one)\n",
210
+ );
211
+ else await printSessions(process.cwd(), stdout);
129
212
  process.exit(0);
130
213
  }
214
+ if (process.argv[2] === "init" || process.argv[2] === "check") {
215
+ const command =
216
+ process.argv[2] === "init"
217
+ ? (await import("./project/init.ts")).runInitCommand
218
+ : (await import("./project/check.ts")).runCheckCommand;
219
+ process.exit(
220
+ await command(process.argv.slice(2), process.cwd(), stdout, process.stderr),
221
+ );
222
+ }
223
+ if (process.argv[2] === "media") {
224
+ const { runMediaCommand } = await import("./media/cli.ts");
225
+ process.exit(
226
+ await runMediaCommand(
227
+ process.argv.slice(2),
228
+ process.cwd(),
229
+ stdout,
230
+ process.stderr,
231
+ ),
232
+ );
233
+ }
131
234
  if (process.argv[2] === "render") {
132
235
  const { runRenderCommand } = await import("./render.ts");
133
236
  process.exit(
@@ -139,8 +242,28 @@ if (process.argv[2] === "render") {
139
242
  ),
140
243
  );
141
244
  }
245
+ if (args.has("--version") || args.has("-v")) {
246
+ stdout.write(`dawg ${await packageVersion()}\n`);
247
+ process.exit(0);
248
+ }
249
+ if (args.has("--help") || args.has("-h")) {
250
+ stdout.write(`${HELP_TEXT}\n`);
251
+ process.exit(0);
252
+ }
253
+ // Argument mistakes are rejected here, before `.dawg/` could be created.
254
+ {
255
+ const problem = argumentProblem(process.argv.slice(2));
256
+ if (problem) {
257
+ process.stderr.write(`${problem} · dawg --help\n`);
258
+ process.exit(2);
259
+ }
260
+ }
142
261
  const demo =
143
262
  args.has("--demo") || process.env.DAWG_DEMO === "1" || !stdin.isTTY;
263
+ // First run with no provider (or a saved one that stopped working): the
264
+ // sign-in picker, in the shell, before the TUI takes the screen.
265
+ if (!demo && process.env.DAWG_AI !== "0" && stdout.isTTY)
266
+ await runFirstRunLogin();
144
267
 
145
268
  const initial = createScore({
146
269
  tracks: [
@@ -162,6 +285,11 @@ const selectedSession = args.has("--new")
162
285
  },
163
286
  );
164
287
  if (selectedSession !== undefined) sessionOptions.sessionId = selectedSession;
288
+ /** True when this launch creates `.dawg/`; the strip says so once. */
289
+ const freshWorkspace = !(await stat(join(process.cwd(), ".dawg")).then(
290
+ () => true,
291
+ () => false,
292
+ ));
165
293
  const session = await ensureSession(initial.toJSON(), sessionOptions);
166
294
  // dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
167
295
  let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
@@ -181,7 +309,7 @@ if (importPath) {
181
309
  record,
182
310
  {
183
311
  kind: "score.import",
184
- payload: { path: importPath, before: record.composition },
312
+ payload: { path: importPath },
185
313
  },
186
314
  score.toJSON(),
187
315
  );
@@ -204,8 +332,9 @@ if (!draftTrack) await ensureFocusedTrack();
204
332
 
205
333
  const clock = new TransportClock(score.tempoBpm);
206
334
  let audio = port.player;
207
- let selectedModel: GatewayModel =
208
- process.env.DAWG_MODEL === "opus-5.5" ? "opus-5.5" : "sol-6.1";
335
+ /** Session and today's spend, from the usage each response reports. */
336
+ const meter = new SpendMeter(configDir());
337
+ void meter.load().catch(() => undefined);
209
338
  const prompt = new PromptModel({ width: 72, maxVisualRows: 8 });
210
339
  const tui = new TuiApp({
211
340
  io: {
@@ -220,10 +349,21 @@ const tui = new TuiApp({
220
349
  });
221
350
  let syncState: SyncState = port.sync;
222
351
  let windowCount = 1;
352
+ /** Project file sync when `dawg.json` is in the working directory. */
353
+ let projectSync: ProjectSync | undefined;
354
+ let typesIndicator: TypesIndicator | undefined;
223
355
  let announcedName = record.meta.name;
224
356
  let namer = makeNamer();
225
357
  /** Re-subscribes the TUI after /fork or /resume replaces `port`. */
226
358
  let rebindPort: () => void = () => undefined;
359
+ /** Play mode's controller; kept across entries so settings persist. */
360
+ let play: PlaySession | undefined;
361
+ /** The hand-editing menu (`/menu`, Ctrl-K), drawn as the picker overlay. */
362
+ const menu = new EditMenu();
363
+ /** Daemon windows play no loop; play mode monitors through its own engine. */
364
+ let monitorEngine: AudioEngine | undefined;
365
+ /** The decoded sampler voices, for play mode's live voices. */
366
+ let liveSampleBank: SampleBank | undefined;
227
367
  /** The in-flight agent turn: Esc aborts it, Enter steers it. */
228
368
  let agentTurn: { controller: AbortController; steering: string[] } | undefined;
229
369
  let reportAgentActivity: (text: string) => void = () => undefined;
@@ -233,6 +373,35 @@ let agentEventSink: (event: AgentEvent) => void = () => undefined;
233
373
  let provider: Promise<ProviderSelection> | undefined;
234
374
  let providerStamp = { fingerprint: "", at: 0, offline: false };
235
375
  let providerName = "";
376
+ /** Undo/redo key hints shown so far; only the first few receipts carry one. */
377
+ let undoHintsShown = 0;
378
+ const MAX_UNDO_HINTS = 3;
379
+ let invalidNoticeShown = false;
380
+ /** True while /login has handed the terminal to a shell flow. */
381
+ let screenSuspended = false;
382
+ /** Redraw from outside the input loop (usage arrives asynchronously). */
383
+ let tickUi: () => void = () => undefined;
384
+ /** Suspend the TUI around an interactive shell flow (set by runInteractive). */
385
+ let handoff: <T>(flow: () => Promise<T>) => Promise<T> = (flow) => flow();
386
+
387
+ /** `$0.12 session · $0.48 today · opus-5.5 · gateway`, sized to the width. */
388
+ function spendLine(): string {
389
+ const width = stdout.columns ?? 80;
390
+ if (!providerName || providerName === "offline")
391
+ return width >= 40 ? "no model · dawg login" : "";
392
+ const [model, provider] = providerName.split(" · ");
393
+ return formatSpendLine(
394
+ {
395
+ kind: providerSnapshot?.kind === "xcb" ? "subscription" : "api",
396
+ ...(model ? { model } : {}),
397
+ ...(provider ? { provider } : {}),
398
+ sessionUsd: meter.sessionUsd,
399
+ todayUsd: meter.todayUsd,
400
+ },
401
+ Math.max(0, width - 8),
402
+ );
403
+ }
404
+ let providerSnapshot: ProviderSelection | undefined;
236
405
  if (demo) {
237
406
  if (score.notes.length === 0) score = seedDemo(score, requestedTrack);
238
407
  if (exportPath)
@@ -254,6 +423,32 @@ if (exportPath) {
254
423
 
255
424
  await runInteractive();
256
425
 
426
+ /** Describes an unknown subcommand or option in `argv`, or undefined. */
427
+ function argumentProblem(argv: readonly string[]): string | undefined {
428
+ if (argv[0] && !argv[0].startsWith("-") && !SUBCOMMANDS.includes(argv[0]))
429
+ return `unknown command · ${argv[0]}`;
430
+ if (argv[0] && SUBCOMMANDS.includes(argv[0])) return undefined;
431
+ for (let index = 0; index < argv.length; index += 1) {
432
+ const arg = argv[index]!;
433
+ if (VALUE_FLAGS.includes(arg)) {
434
+ index += 1;
435
+ continue;
436
+ }
437
+ if (arg.startsWith("-") && !FLAGS.includes(arg))
438
+ return `unknown option · ${arg}`;
439
+ if (!arg.startsWith("-")) return `unknown command · ${arg}`;
440
+ }
441
+ return undefined;
442
+ }
443
+
444
+ async function packageVersion(): Promise<string> {
445
+ const raw = await readFile(
446
+ new URL("../package.json", import.meta.url),
447
+ "utf8",
448
+ );
449
+ return (JSON.parse(raw) as { version?: string }).version ?? "0.0.0";
450
+ }
451
+
257
452
  function optionValue(name: string): string | undefined {
258
453
  const index = process.argv.indexOf(name);
259
454
  const value = index >= 0 ? process.argv[index + 1] : undefined;
@@ -356,6 +551,10 @@ function snapshot(
356
551
  value.tracks.find((track) => track.id === requestedTrack)?.instrument,
357
552
  notes,
358
553
  ),
554
+ ...samplerSnapshotFields(
555
+ value.tracks.find((track) => track.id === requestedTrack),
556
+ notes,
557
+ ),
359
558
  layers:
360
559
  tui.highwayView === "all"
361
560
  ? highwayLayers(
@@ -390,33 +589,65 @@ function appView(value: TrackScore, beat: number): AppView {
390
589
  return {
391
590
  score: snapshot(value, beat),
392
591
  beat,
393
- // `opus-5.5 · gateway`, `claude/sonnet · xcb`; hidden when offline.
592
+ // `opus-5.5 · gateway`, `sonnet · claude`; hidden when offline.
394
593
  model:
395
594
  providerName && providerName !== "offline" ? providerName : undefined,
595
+ spend: spendLine(),
596
+ agentOffline: providerName === "offline" || process.env.DAWG_AI === "0",
396
597
  sync: syncState,
397
598
  sessionName: record.meta.name,
398
599
  windows: windowCount,
600
+ types: typesIndicator,
601
+ play: play?.on ? play.header() : undefined,
399
602
  };
400
603
  }
401
604
 
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
- });
605
+ /** What the strip compares a receipt against: the revision and score before. */
606
+ type Baseline = { revision: number; score: TrackScore };
607
+ function baseline(): Baseline {
608
+ return { revision: record.revision, score };
609
+ }
610
+
611
+ /**
612
+ * Show a command receipt in the activity strip. The tone comes from the
613
+ * receipt itself (strings fall back to a legacy regex); the revision label
614
+ * appears when the revision moved, and the undo hint only when the score
615
+ * changed in this session (not for forks, resumes or transport).
616
+ */
617
+ function receipt(result: string | Receipt, base?: Baseline): void {
618
+ const message = typeof result === "string" ? result : result.text;
619
+ const tone = toneOf(result);
620
+ const changed = base !== undefined && record.revision !== base.revision;
621
+ const scoreChanged =
622
+ changed && record.sessionId !== undefined && score !== base.score;
623
+ if (tone === "error") {
624
+ tui.activity.pushError(message);
625
+ return;
626
+ }
627
+ const hint =
628
+ scoreChanged && undoHintsShown < MAX_UNDO_HINTS
629
+ ? message.startsWith("undid")
630
+ ? "^y redo"
631
+ : "^z undo"
632
+ : undefined;
633
+ if (hint) undoHintsShown += 1;
634
+ tui.activity.pushCard(message, {
635
+ tone,
636
+ baseRevision: changed ? base.revision : undefined,
637
+ resultRevision: changed ? record.revision : undefined,
638
+ hint,
639
+ trackId: requestedTrack,
640
+ });
641
+ }
642
+
643
+ /** One shape for thrown errors: `<what> · <why> · <next step>`. */
644
+ function describeError(command: string, error: unknown): string {
645
+ const detail = error as { code?: unknown; path?: unknown } | undefined;
646
+ const message = error instanceof Error ? error.message : String(error);
647
+ if (detail?.code === "ENOENT" && typeof detail.path === "string")
648
+ return `no such file · ${relative(process.cwd(), detail.path)}`;
649
+ const verb = command.trim().split(/\s+/)[0]?.replace(/^\//, "") || "command";
650
+ return `${verb} failed · ${message}`;
420
651
  }
421
652
 
422
653
  function truncateForCard(value: string): string {
@@ -439,7 +670,7 @@ async function runInteractive(): Promise<void> {
439
670
  return;
440
671
  }
441
672
  tui.activity.pushRequest(text);
442
- const base = record.revision;
673
+ const base = baseline();
443
674
  const baseSession = record.sessionId;
444
675
  try {
445
676
  const message = await submit(text);
@@ -450,12 +681,10 @@ async function runInteractive(): Promise<void> {
450
681
  namer.noteTurn({
451
682
  score,
452
683
  prompt: text,
453
- accepted: record.revision !== base,
684
+ accepted: record.revision !== base.revision,
454
685
  });
455
686
  } catch (error) {
456
- tui.activity.pushError(
457
- `error · ${error instanceof Error ? error.message : String(error)}`,
458
- );
687
+ tui.activity.pushError(describeError(text, error));
459
688
  } finally {
460
689
  agentReported = false;
461
690
  }
@@ -477,17 +706,22 @@ async function runInteractive(): Promise<void> {
477
706
  }
478
707
  };
479
708
  const tick = (force = false) => {
709
+ if (screenSuspended) return;
710
+ play?.tick();
711
+ // Values in the menu follow the score as edits land.
712
+ if (menu.open) refreshMenu();
480
713
  tui.render(appView(score, clock.beatAt()), { force });
481
714
  };
482
715
  reportAgentActivity = () => {
483
716
  tui.activity.applyAgentEvent({ type: "start", model: providerName });
484
717
  };
485
718
  agentEventSink = (event) => {
719
+ if (event.type === "usage") return;
486
720
  tui.activity.applyAgentEvent(event);
487
721
  if (event.type === "done" || event.type === "error") agentReported = true;
488
722
  };
489
723
  // ~30 fps cap; the differential writer only emits changed rows.
490
- const timer = setInterval(tick, tui.frameIntervalMs);
724
+ let timer = setInterval(tick, tui.frameIntervalMs);
491
725
  const onResize = () => {
492
726
  tui.invalidate();
493
727
  tick(true);
@@ -533,6 +767,7 @@ async function runInteractive(): Promise<void> {
533
767
  else if (payload.action === "pause") await setTransport("pause");
534
768
  else if (payload.action === "toggle") await setTransport("toggle");
535
769
  }
770
+ projectSync?.scoreChanged(score);
536
771
  tui.activity.pushCard("synced from another window", {
537
772
  tone: "info",
538
773
  baseRevision: previousRevision,
@@ -581,6 +816,10 @@ async function runInteractive(): Promise<void> {
581
816
  unsubscribe = port.subscribe(onUpdate);
582
817
  refreshPresence();
583
818
  };
819
+ if (freshWorkspace)
820
+ tui.activity.pushCard("created .dawg/ · add it to .gitignore", {
821
+ tone: "info",
822
+ });
584
823
  if (attachNotice)
585
824
  tui.activity.pushCard(attachNotice, {
586
825
  tone: "info",
@@ -589,45 +828,124 @@ async function runInteractive(): Promise<void> {
589
828
  });
590
829
  if (port.status !== "file session")
591
830
  tui.activity.pushCard(port.status, { tone: "info" });
831
+ if (await isProject(process.cwd()))
832
+ projectSync = startProjectSync(syncHost());
592
833
  void currentProvider().then(() => tick(true));
834
+ tickUi = () => tick(true);
835
+ reportSampleProblems(score);
836
+ // Input arrives through a detachable listener (not `for await`), so /login
837
+ // can hand the terminal to an interactive shell flow and take it back.
838
+ const inbox: string[] = [];
839
+ let wake: (() => void) | undefined;
840
+ const onData = (chunk: Buffer | string) => {
841
+ inbox.push(String(chunk));
842
+ wake?.();
843
+ };
844
+ const onEnd = () => {
845
+ inbox.push("\u0000eof");
846
+ wake?.();
847
+ };
848
+ stdin.on("data", onData);
849
+ stdin.on("end", onEnd);
850
+ async function* chunks(): AsyncGenerator<string> {
851
+ for (;;) {
852
+ while (inbox.length === 0)
853
+ await new Promise<void>((resolve) => (wake = resolve));
854
+ wake = undefined;
855
+ const next = inbox.shift()!;
856
+ if (next === "\u0000eof") return;
857
+ yield next;
858
+ }
859
+ }
860
+ handoff = async <T>(flow: () => Promise<T>): Promise<T> => {
861
+ if (screenSuspended) return flow();
862
+ screenSuspended = true;
863
+ clearInterval(timer);
864
+ stdin.off("data", onData);
865
+ stdin.pause();
866
+ stdin.setRawMode?.(false);
867
+ // Leave the alternate screen; restore the cursor and plain paste.
868
+ stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
869
+ try {
870
+ return await flow();
871
+ } finally {
872
+ stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
873
+ stdin.setRawMode?.(true);
874
+ stdin.on("data", onData);
875
+ stdin.resume();
876
+ screenSuspended = false;
877
+ timer = setInterval(tick, tui.frameIntervalMs);
878
+ tui.invalidate();
879
+ tick(true);
880
+ }
881
+ };
593
882
  tick(true);
594
883
  try {
595
884
  let exiting = false;
596
- for await (const chunk of stdin) {
597
- const text = String(chunk);
885
+ for await (const text of chunks()) {
598
886
  // A read that is exactly ESC is the Esc key, not the start of a sequence.
599
887
  const values = [
600
888
  ...inputDecoder.push(text),
601
889
  ...(text === "\u001b" ? inputDecoder.flush() : []),
602
890
  ];
603
891
  for (const value of values) {
892
+ // The edit menu owns every key while it is up (Esc backs out).
893
+ if (typeof value === "string" && menu.open) {
894
+ if (tui.ui.overlay !== "picker" || tui.ui.picker?.id !== "menu")
895
+ menu.close();
896
+ else {
897
+ const result = menu.key(value, menuContext());
898
+ if (result.type === "close") tui.closePicker();
899
+ else if (result.type === "run") {
900
+ queuedPrompts.unshift(result.command);
901
+ void drainQueue();
902
+ }
903
+ if (result.type !== "pass") {
904
+ refreshMenu();
905
+ tick(true);
906
+ continue;
907
+ }
908
+ }
909
+ }
910
+ // Ctrl-K opens the menu on an empty prompt (in play mode too); with
911
+ // text it keeps its kill-to-end-of-line meaning.
912
+ if (
913
+ value === "\u000b" &&
914
+ prompt.value.length === 0 &&
915
+ tui.ui.overlay === undefined
916
+ ) {
917
+ openMenu();
918
+ tick(true);
919
+ continue;
920
+ }
921
+ if (typeof value === "string" && play?.on && playKey(value)) {
922
+ tick(true);
923
+ continue;
924
+ }
925
+ // Ctrl-P enters play mode. A bare `p` would steal the first letter of
926
+ // `pan`, `pattern`, `play` and every prose request starting with p.
927
+ if (value === "\u0010" && tui.ui.overlay === undefined) {
928
+ void enterPlay()
929
+ .then((outcome) => receipt(outcome))
930
+ .finally(() => tick(true));
931
+ continue;
932
+ }
604
933
  // The prompt is always focused, so ordinary `q` must remain typeable in
605
934
  // requests (for example, "quiet hi-hat"). Ctrl-C is the unambiguous
606
935
  // shell exit key; Ctrl-Q is reserved for prompt mode switching.
607
936
  // Keep the empty-prompt space shortcut for transport, while allowing
608
937
  // ordinary spaces once a request is being composed.
609
938
  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
- }
939
+ // Never await a daemon round trip here: the key loop must stay
940
+ // live for Esc, quit and redraws while the toggle is in flight.
941
+ void toggleTransport()
942
+ .catch((error: unknown) => {
943
+ tui.activity.pushCard(
944
+ `transport failed · ${error instanceof Error ? error.message : String(error)}`,
945
+ { tone: "error" },
946
+ );
947
+ })
948
+ .finally(() => tick(true));
631
949
  } else {
632
950
  const input = tui.input(value);
633
951
  const action = input.type === "action" ? input.action : undefined;
@@ -637,8 +955,17 @@ async function runInteractive(): Promise<void> {
637
955
  (input.command === "undo" || input.command === "redo") &&
638
956
  !agentTurn
639
957
  ) {
640
- const base = record.revision;
641
- receipt(await stepHistory(input.command), base);
958
+ const base = baseline();
959
+ const command = input.command;
960
+ void stepHistory(command)
961
+ .then((outcome) => receipt(outcome, base))
962
+ .catch((error: unknown) => {
963
+ tui.activity.pushCard(
964
+ `${command} failed · ${error instanceof Error ? error.message : String(error)}`,
965
+ { tone: "error" },
966
+ );
967
+ })
968
+ .finally(() => tick(true));
642
969
  }
643
970
  } else if (input.type === "pick") {
644
971
  // Picker choices run as the command they stand for.
@@ -678,11 +1005,16 @@ async function runInteractive(): Promise<void> {
678
1005
  if (exiting) break;
679
1006
  }
680
1007
  } finally {
1008
+ stdin.off("data", onData);
1009
+ stdin.off("end", onEnd);
681
1010
  clearInterval(timer);
682
1011
  clearInterval(presenceTimer);
1012
+ await projectSync?.stop();
683
1013
  namer.dispose();
684
1014
  stdout.off("resize", onResize);
685
1015
  unsubscribe();
1016
+ await play?.exit().catch(() => undefined);
1017
+ await monitorEngine?.dispose().catch(() => undefined);
686
1018
  audio.stop();
687
1019
  await port.close();
688
1020
  stdin.setRawMode?.(false);
@@ -691,23 +1023,84 @@ async function runInteractive(): Promise<void> {
691
1023
  }
692
1024
  }
693
1025
 
694
- async function submit(prompt: string): Promise<string> {
1026
+ async function submit(prompt: string): Promise<string | Receipt> {
695
1027
  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(" ");
1028
+ if (/^\/?help$|^\/?\?$/.test(command.toLowerCase())) {
1029
+ tui.openText("help", helpLines(Math.max(40, (stdout.columns ?? 80) - 8)));
1030
+ return ok("help · esc closes");
1031
+ }
1032
+ if (/^\/?tracks$/i.test(command)) {
1033
+ const problems = await sampleProblems(score);
1034
+ const lines = score.tracks.map((track) => {
1035
+ const voices = track.sampler
1036
+ ? Object.keys(track.sampler.voices).length
1037
+ : 0;
1038
+ const missing = problems.filter(
1039
+ (problem) => problem.trackId === track.id && problem.level === "error",
1040
+ ).length;
1041
+ const samples = track.sampler
1042
+ ? ` · ${voices} sample${voices === 1 ? "" : "s"}${missing ? ` · ${missing} missing` : ""}`
1043
+ : "";
1044
+ return `${track.id === requestedTrack ? "*" : " "} ${track.id} · ${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`;
1045
+ });
1046
+ tui.openText("tracks · * focused", lines);
1047
+ return ok(
1048
+ `${lines.length} track${lines.length === 1 ? "" : "s"} · esc closes`,
1049
+ );
1050
+ }
1051
+ const playCommand = command.match(/^\/play(?:\s+(on|off))?$/i);
1052
+ if (playCommand) {
1053
+ const wanted = playCommand[1]?.toLowerCase();
1054
+ if (wanted === "off" || (wanted === undefined && play?.on))
1055
+ return exitPlay();
1056
+ return enterPlay();
1057
+ }
1058
+ const clickCommand = command.match(/^\/click(?:\s+(.+))?$/i);
1059
+ if (clickCommand) {
1060
+ const message = playSession().clickCommand(clickCommand[1] ?? "");
1061
+ return message.startsWith("usage") || message.startsWith("click volume")
1062
+ ? fail(message)
1063
+ : ok(message);
1064
+ }
1065
+ const countIn = command.match(/^\/count-?in\s+([0-2])$/i);
1066
+ if (countIn) return ok(playSession().setCountIn(Number(countIn[1])));
1067
+ const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
1068
+ if (menuCommand) {
1069
+ const section = menuCommand[1]?.toLowerCase();
1070
+ if (section && !(MENU_SECTIONS as readonly string[]).includes(section))
1071
+ return fail(`/menu [${MENU_SECTIONS.join("|")}]`);
1072
+ openMenu(section);
1073
+ return ok("menu · esc closes");
1074
+ }
1075
+ const gridCommand = command.match(/^\/grid\s+(\S+)$/i);
1076
+ if (gridCommand) {
1077
+ const message = playSession().setGrid(gridCommand[1]!);
1078
+ return message
1079
+ ? ok(message)
1080
+ : fail("usage: /grid 1/4|1/8|1/8T|1/16|1/16T|1/32");
1081
+ }
705
1082
  if (/^\/status$/i.test(command))
706
- return `status · ${record.meta.name} · rev ${record.revision} · ${compositionDigest(record.composition)} · ${port.mode}`;
1083
+ return ok(
1084
+ `status · ${record.meta.name} · rev ${record.revision} · ${compositionDigest(record.composition)} · ${port.mode === "daemon" ? "shared via dawgd" : "saved locally · no daemon"}`,
1085
+ );
707
1086
  if (/^\/?undo$/i.test(command)) return stepHistory("undo");
708
1087
  if (/^\/?redo$/i.test(command)) return stepHistory("redo");
1088
+ const trackCommand = command.match(
1089
+ /^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
1090
+ );
1091
+ if (trackCommand) return focusTrack(trackCommand[1]!.toLowerCase());
1092
+ const sample = parseSampleCommand(command);
1093
+ if (sample) return sampleCommand(sample);
709
1094
  const sessionReply = await sessionCommand(command);
710
1095
  if (sessionReply !== undefined) return sessionReply;
1096
+ const edit = parseEditCommand(command);
1097
+ if (edit) {
1098
+ await materializeDraft();
1099
+ const result = applyEditCommand(score, requestedTrack, edit);
1100
+ if (result.next && result.kind)
1101
+ await commitScore(result.next, result.kind, result.payload);
1102
+ return result.ok ? ok(result.message) : fail(result.message);
1103
+ }
711
1104
  const music = parseMusicCommand(command);
712
1105
  if (music) {
713
1106
  await materializeDraft();
@@ -716,7 +1109,7 @@ async function submit(prompt: string): Promise<string> {
716
1109
  );
717
1110
  if (result.next && result.kind)
718
1111
  await commitScore(result.next, result.kind, result.payload);
719
- return result.message;
1112
+ return result.ok ? ok(result.message) : fail(result.message);
720
1113
  }
721
1114
  const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
722
1115
  if (exportCommand) {
@@ -731,33 +1124,56 @@ async function submit(prompt: string): Promise<string> {
731
1124
  await commitScore(imported, "score.import", { path: importCommand[1] });
732
1125
  return `imported · ${importCommand[1]}`;
733
1126
  }
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) {
1127
+ const modelMatch = prompt.trim().match(/^\/model(?:\s+(\S+))?\s*$/i);
1128
+ if (modelMatch) {
1129
+ const selection = await currentProvider();
1130
+ if (!modelMatch[1]) {
1131
+ if (selection.kind === "offline")
1132
+ return `model · none · ${selection.reason}`;
1133
+ tui.activity.setSpinner("models");
1134
+ try {
1135
+ const items = await modelPickerItems(selection);
1136
+ if (items.length === 0)
1137
+ return `model · ${providerName} · no other models listed`;
745
1138
  tui.openPicker({
746
- id: "xcb",
747
- title: "xcb account · model · ↑/↓ Enter · Esc",
1139
+ id: "model",
1140
+ title: `model · ${providerName}`,
748
1141
  items,
1142
+ filterable: true,
1143
+ index: Math.max(
1144
+ 0,
1145
+ items.findIndex((item) => item.current),
1146
+ ),
749
1147
  });
750
- return `xcb · ${items.length} choices`;
1148
+ return `model · ${providerName} · pick one (or /model <alias>)`;
1149
+ } finally {
1150
+ tui.activity.setSpinner(undefined);
751
1151
  }
752
- } finally {
753
- tui.activity.setSpinner(undefined);
754
1152
  }
755
- // Zero or one choice: the plain flow saves it or prints guidance.
1153
+ const receiptText = await tuiSetModel(modelMatch[1]);
1154
+ provider = undefined;
1155
+ await currentProvider();
1156
+ return receiptText;
1157
+ }
1158
+ if (/^\/login\b/i.test(prompt.trim())) {
1159
+ const parsed = tuiLoginArgs(prompt.trim());
1160
+ if (typeof parsed === "string") return `login · ${parsed}`;
1161
+ await handoff(() =>
1162
+ runTuiLogin(
1163
+ parsed.target === "auto" ? "pick" : parsed.target,
1164
+ parsed.options,
1165
+ ),
1166
+ );
1167
+ provider = undefined;
1168
+ await currentProvider();
1169
+ return providerName === "offline"
1170
+ ? "login · not signed in · direct commands still work"
1171
+ : `signed in · ${providerName}`;
756
1172
  }
757
- if (/^\/(login|logout|auth)\b/i.test(prompt.trim())) {
1173
+ if (/^\/(logout|auth)\b/i.test(prompt.trim())) {
758
1174
  tui.activity.setSpinner(prompt.trim().split(/\s+/)[0]!.slice(1));
759
1175
  try {
760
- const lines = await tuiAuthCommand(prompt.trim(), selectedModel);
1176
+ const lines = await tuiAuthCommand(prompt.trim());
761
1177
  provider = undefined;
762
1178
  await currentProvider();
763
1179
  for (const line of lines.slice(0, -1))
@@ -767,9 +1183,23 @@ async function submit(prompt: string): Promise<string> {
767
1183
  tui.activity.setSpinner(undefined);
768
1184
  }
769
1185
  }
1186
+ // `/model` belongs to its own handler above; every other slash word that
1187
+ // reached here is unknown or misused, and never a question for the agent.
1188
+ if (command.startsWith("/") && !/^\/model\b/i.test(command)) {
1189
+ const hint = usageHint(command);
1190
+ return fail(
1191
+ hint
1192
+ ? `${truncateForCard(command)} · ${hint}`
1193
+ : `unknown command ${command.split(/\s+/)[0]} · /help`,
1194
+ );
1195
+ }
770
1196
  const parsed = parsePrompt(prompt);
771
1197
  if (!parsed) {
772
- if (process.env.DAWG_AI === "0") return `unrecognized request: ${prompt}`;
1198
+ // A known verb with bad arguments gets usage, not a model call.
1199
+ const hint = usageHint(command);
1200
+ if (hint) return fail(`${truncateForCard(command)} · ${hint}`);
1201
+ if (process.env.DAWG_AI === "0")
1202
+ return fail(`unrecognized · ${truncateForCard(command)} · /help`);
773
1203
  await materializeDraft();
774
1204
  return runAgent(prompt);
775
1205
  }
@@ -791,7 +1221,7 @@ async function submit(prompt: string): Promise<string> {
791
1221
  );
792
1222
  } catch (error) {
793
1223
  if (error instanceof SessionConflictError)
794
- return "transport changed in another window";
1224
+ return warn("transport changed in another window");
795
1225
  throw error;
796
1226
  }
797
1227
  return parsed.action;
@@ -802,20 +1232,7 @@ async function submit(prompt: string): Promise<string> {
802
1232
  clock.setTempo?.(next.tempoBpm);
803
1233
  return `tempo · ${next.tempoBpm} BPM`;
804
1234
  }
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
- }
1235
+ if (parsed.type === "add-track") return focusTrack(parsed.trackId);
819
1236
  if (parsed.type === "set-bars" || parsed.type === "extend-bars") {
820
1237
  const bars =
821
1238
  parsed.type === "set-bars"
@@ -940,7 +1357,7 @@ async function submit(prompt: string): Promise<string> {
940
1357
  latest,
941
1358
  {
942
1359
  kind: "score.operation",
943
- payload: { operation, before: latest.composition },
1360
+ payload: { operation },
944
1361
  },
945
1362
  next.toJSON(),
946
1363
  );
@@ -951,7 +1368,151 @@ async function submit(prompt: string): Promise<string> {
951
1368
  if (!(error instanceof SessionConflictError)) throw error;
952
1369
  }
953
1370
  }
954
- return "session busy; retry the note";
1371
+ return warn("session busy · retry the note");
1372
+ }
1373
+
1374
+ /**
1375
+ * `/track <name>`: focus `trackId` in this window, creating it when it is
1376
+ * not in the score yet. A track another live window has focused stays
1377
+ * theirs; focus goes through the port so presence is right everywhere.
1378
+ */
1379
+ async function focusTrack(trackId: string): Promise<Receipt> {
1380
+ if (trackId === requestedTrack && !draftTrack)
1381
+ return warn(`already on ${trackId}`);
1382
+ const clients = await port.presence().catch(() => []);
1383
+ if (
1384
+ clients.some(
1385
+ (client) =>
1386
+ client.clientId !== port.clientId && client.focusedTrackId === trackId,
1387
+ )
1388
+ )
1389
+ return warn(`${trackId} is open in another window`);
1390
+ const exists = score.tracks.some((track) => track.id === trackId);
1391
+ if (!exists) {
1392
+ const next = applyScoreOperation(score, {
1393
+ type: "addTrack",
1394
+ track: {
1395
+ id: trackId,
1396
+ name: trackId,
1397
+ instrument: isDrumInstrument(trackId) ? "kit" : "sine",
1398
+ },
1399
+ });
1400
+ await commitScore(next, "track.create", { trackId });
1401
+ }
1402
+ await port.focus(trackId);
1403
+ requestedTrack = trackId;
1404
+ draftTrack = false;
1405
+ return ok(exists ? `track · ${trackId}` : `track created · ${trackId}`);
1406
+ }
1407
+
1408
+ let sampleLibrary: SampleLibrary | undefined;
1409
+ let reportedSampleProblems = "";
1410
+
1411
+ /** Decode every sampler voice (cached) and return what failed to load. */
1412
+ async function sampleProblems(
1413
+ value: TrackScore,
1414
+ ): Promise<readonly SampleProblem[]> {
1415
+ if (!hasSamplerTracks(value)) return [];
1416
+ sampleLibrary ??= new SampleLibrary({ projectRoot: process.cwd() });
1417
+ try {
1418
+ const bank = await sampleLibrary.load(value);
1419
+ liveSampleBank = bank;
1420
+ return bank.problems;
1421
+ } catch (error) {
1422
+ return [
1423
+ {
1424
+ trackId: "",
1425
+ voice: "",
1426
+ src: "",
1427
+ level: "error",
1428
+ message: `samples · ${error instanceof Error ? error.message : String(error)} · check the sample files`,
1429
+ },
1430
+ ];
1431
+ }
1432
+ }
1433
+
1434
+ /** Surface sample load problems once per distinct set, as receipts. */
1435
+ function reportSampleProblems(value: TrackScore): void {
1436
+ if (!hasSamplerTracks(value)) return;
1437
+ void sampleProblems(value).then((problems) => {
1438
+ const key = problems.map((problem) => problem.message).join("\n");
1439
+ if (key === reportedSampleProblems) return;
1440
+ reportedSampleProblems = key;
1441
+ for (const problem of problems.slice(0, 3))
1442
+ tui.activity.pushCard(`sample ${problem.voice} · ${problem.message}`, {
1443
+ tone: problem.level === "error" ? "error" : "warning",
1444
+ trackId: problem.trackId || undefined,
1445
+ });
1446
+ if (problems.length > 3)
1447
+ tui.activity.pushCard(
1448
+ `${problems.length - 3} more sample problems · /tracks`,
1449
+ {
1450
+ tone: "warning",
1451
+ },
1452
+ );
1453
+ tickUi();
1454
+ });
1455
+ }
1456
+
1457
+ async function sampleCommand(
1458
+ command: NonNullable<ReturnType<typeof parseSampleCommand>>,
1459
+ ): Promise<Receipt> {
1460
+ if (command.kind === "list") {
1461
+ const track = score.tracks.find((item) => item.id === requestedTrack);
1462
+ const lines = listSampleVoices(track);
1463
+ if (lines.length === 0)
1464
+ return warn(
1465
+ `sample · ${requestedTrack} has no samples · /sample <path> [as <voice>]`,
1466
+ );
1467
+ tui.openText(`samples · ${requestedTrack}`, lines);
1468
+ return ok(
1469
+ `${lines.length} sample${lines.length === 1 ? "" : "s"} · esc closes`,
1470
+ );
1471
+ }
1472
+ await materializeDraft();
1473
+ const trackId = samplerTarget(score, requestedTrack);
1474
+ const voice =
1475
+ command.voice ?? freeVoiceName(score, trackId, voiceNameFrom(command.path));
1476
+ let placed;
1477
+ try {
1478
+ placed = await placeSampleFile({
1479
+ projectRoot: process.cwd(),
1480
+ cwd: process.cwd(),
1481
+ input: command.path,
1482
+ score,
1483
+ trackId,
1484
+ voice,
1485
+ });
1486
+ } catch (error) {
1487
+ if (error instanceof SamplePlacementError)
1488
+ return fail(`sample · ${error.message} · check the path and retry`);
1489
+ throw error;
1490
+ }
1491
+ const result = addSampleVoice(score, trackId, voice, {
1492
+ src: placed.src,
1493
+ sha256: placed.sha256,
1494
+ });
1495
+ if (!result.ok) return fail(result.message);
1496
+ // Decode before committing so a bad file is a receipt, not a silent voice.
1497
+ const problems = (await sampleProblems(result.next)).filter(
1498
+ (problem) => problem.trackId === trackId && problem.voice === voice,
1499
+ );
1500
+ if (problems.some((problem) => problem.level === "error"))
1501
+ return fail(`sample · ${problems[0]!.message}`);
1502
+ await commitScore(result.next, "sample.add", {
1503
+ trackId,
1504
+ voice,
1505
+ src: placed.src,
1506
+ });
1507
+ if (trackId !== requestedTrack) {
1508
+ await port.focus(trackId);
1509
+ requestedTrack = trackId;
1510
+ draftTrack = false;
1511
+ }
1512
+ await projectSync?.flushScore();
1513
+ return ok(
1514
+ `${result.message}${placed.copied ? ` · copied to ${placed.src}` : ""}`,
1515
+ );
955
1516
  }
956
1517
 
957
1518
  async function readLoopFile(path: string): Promise<string> {
@@ -1002,7 +1563,12 @@ function adoptMeta(meta: typeof record.meta): void {
1002
1563
  record = { ...record, meta };
1003
1564
  if (meta.name !== announcedName) {
1004
1565
  announcedName = meta.name;
1005
- tui.activity.pushCard(`session · ${meta.name}`, { tone: "info" });
1566
+ tui.activity.pushCard(
1567
+ meta.nameSource === "auto"
1568
+ ? `${meta.name} (auto-named) · rename with /rename <name>`
1569
+ : `session · ${meta.name}`,
1570
+ { tone: "info" },
1571
+ );
1006
1572
  }
1007
1573
  }
1008
1574
 
@@ -1022,7 +1588,9 @@ function makeNamer(): AutoNamer {
1022
1588
  }
1023
1589
 
1024
1590
  /** `/sessions`, `/resume`, `/rename`, `/fork`; undefined when not one. */
1025
- async function sessionCommand(command: string): Promise<string | undefined> {
1591
+ async function sessionCommand(
1592
+ command: string,
1593
+ ): Promise<string | Receipt | undefined> {
1026
1594
  const match = command.match(/^\/(sessions|resume|rename|fork)(?:\s+(.*))?$/i);
1027
1595
  if (!match) return undefined;
1028
1596
  const verb = match[1]!.toLowerCase();
@@ -1030,7 +1598,9 @@ async function sessionCommand(command: string): Promise<string | undefined> {
1030
1598
  const workspace = process.cwd();
1031
1599
  if (verb === "rename") {
1032
1600
  if (!arg)
1033
- return `session · ${record.meta.name} (${record.meta.nameSource}) · /rename <name> | --auto`;
1601
+ return record.meta.nameSource === "auto"
1602
+ ? `${record.meta.name} (auto-named) · rename with /rename <name>`
1603
+ : `${record.meta.name} (named by you) · /rename --auto to auto-name`;
1034
1604
  if (arg === "--auto") {
1035
1605
  const result = await port.updateMeta({
1036
1606
  nameSource: "auto",
@@ -1051,7 +1621,7 @@ async function sessionCommand(command: string): Promise<string | undefined> {
1051
1621
  const sessions = await listSessions(workspace);
1052
1622
  if (verb === "resume" && !arg) {
1053
1623
  const readable = sessions.filter((session) => !session.error).slice(0, 64);
1054
- if (readable.length === 0) return "no sessions to resume";
1624
+ if (readable.length === 0) return warn("no sessions to resume");
1055
1625
  const current = readable.findIndex(
1056
1626
  (session) => session.sessionId === record.sessionId,
1057
1627
  );
@@ -1064,35 +1634,48 @@ async function sessionCommand(command: string): Promise<string | undefined> {
1064
1634
  })),
1065
1635
  index: Math.max(0, current),
1066
1636
  });
1067
- return "resume · pick a session (or /resume <n|name|id>)";
1637
+ return "resume · 1-9 shown · /resume <name|id>";
1068
1638
  }
1069
1639
  if (verb === "sessions") {
1070
- const lines = pickerLines(sessions, record.sessionId, (session) =>
1071
- formatSessionLine(session, sessions),
1640
+ const lines = pickerLines(
1641
+ sessions,
1642
+ record.sessionId,
1643
+ (session) => formatSessionLine(session, sessions),
1644
+ MAX_LISTED_SESSIONS,
1645
+ );
1646
+ tui.openText("sessions · * current · /resume <n>", lines);
1647
+ return ok(
1648
+ `${lines.length} session${lines.length === 1 ? "" : "s"} · esc closes`,
1072
1649
  );
1073
- for (const line of lines) tui.activity.pushCard(line, { tone: "info" });
1074
- return `${lines.length} sessions`;
1075
1650
  }
1076
- if (agentTurn) return "agent busy; finish or Esc first";
1651
+ if (agentTurn) return warn("agent busy · finish or Esc first");
1077
1652
  if (verb === "fork") {
1078
1653
  const forked = await forkSession(workspace, record, arg || undefined);
1079
1654
  await switchSession(forked.sessionId);
1080
1655
  return `forked · ${forked.meta.name}`;
1081
1656
  }
1082
1657
  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}`;
1658
+ // Any list index works, not only the digits the picker binds.
1659
+ const index = /^\d+$/.test(arg) ? Number(arg) - 1 : -1;
1660
+ let sessionId = readable[index]?.sessionId;
1661
+ if (sessionId === undefined)
1662
+ try {
1663
+ sessionId = await resolveSessionArg(workspace, arg, sessions);
1664
+ } catch (error) {
1665
+ if (!(error instanceof SessionLookupError)) throw error;
1666
+ return fail(error.message.replace(" · dawg sessions", " · /sessions"));
1667
+ }
1668
+ if (sessionId === record.sessionId)
1669
+ return warn(`already in ${record.meta.name}`);
1090
1670
  await switchSession(sessionId);
1091
1671
  return `resumed · ${record.meta.name} · ${requestedTrack}${draftTrack ? " (new)" : ""}`;
1092
1672
  }
1093
1673
 
1094
1674
  /** Leaves the current session and attaches this window to `sessionId`. */
1095
1675
  async function switchSession(sessionId: string): Promise<void> {
1676
+ // Play mode belongs to the old session's engine and track.
1677
+ await play?.exit();
1678
+ play = undefined;
1096
1679
  if (clock.playing) await setTransport("pause");
1097
1680
  namer.dispose();
1098
1681
  await port.close();
@@ -1123,29 +1706,237 @@ async function switchSession(sessionId: string): Promise<void> {
1123
1706
  });
1124
1707
  }
1125
1708
 
1709
+ function liveEngine(): LiveEngine | undefined {
1710
+ if (audio instanceof AudioEngine) return audio;
1711
+ monitorEngine ??= new AudioEngine({ projectRoot: process.cwd() });
1712
+ return monitorEngine;
1713
+ }
1714
+
1715
+ /** The play session for the focused track (re-made when focus moves). */
1716
+ function menuContext(): MenuContext {
1717
+ const session = play;
1718
+ return {
1719
+ score,
1720
+ trackId: requestedTrack,
1721
+ playing: clock.playing,
1722
+ grid: session?.grid ?? DEFAULT_GRID,
1723
+ grids: GRIDS.map((grid) => grid.label),
1724
+ clickOn: session?.clickOn ?? false,
1725
+ countInBars: session?.countInBars ?? 1,
1726
+ };
1727
+ }
1728
+
1729
+ function openMenu(section?: string): void {
1730
+ menu.show(menuContext(), section);
1731
+ refreshMenu();
1732
+ }
1733
+
1734
+ function refreshMenu(): void {
1735
+ if (!menu.open) return;
1736
+ if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "menu") {
1737
+ // Another overlay (help, a picker) replaced the menu.
1738
+ menu.close();
1739
+ return;
1740
+ }
1741
+ const view = menu.view(menuContext());
1742
+ tui.openPicker({
1743
+ id: "menu",
1744
+ title: view.title,
1745
+ items: view.items.length
1746
+ ? view.items
1747
+ : [{ label: "no matches", value: "none" }],
1748
+ index: view.index,
1749
+ hint: view.hint,
1750
+ });
1751
+ }
1752
+
1753
+ function playSession(): PlaySession {
1754
+ if (play && play.track === requestedTrack) return play;
1755
+ const previous = play;
1756
+ play = new PlaySession(playHost(), {
1757
+ clickOn: previous?.clickOn,
1758
+ clickVolume: previous?.clickVolume,
1759
+ });
1760
+ if (previous) {
1761
+ play.countInBars = previous.countInBars;
1762
+ play.grid = previous.grid;
1763
+ }
1764
+ return play;
1765
+ }
1766
+
1767
+ async function enterPlay(): Promise<Receipt> {
1768
+ if (play?.on) return ok(`play · ${play.track} · esc leaves`);
1769
+ await materializeDraft();
1770
+ const session = playSession();
1771
+ await session.enter();
1772
+ if (hasSamplerTracks(score)) void sampleProblems(score);
1773
+ return ok(
1774
+ `play · ${session.track} · ${session.keyboard.range} · z/x octave · r record · m click · esc leaves`,
1775
+ );
1776
+ }
1777
+
1778
+ async function exitPlay(): Promise<Receipt> {
1779
+ if (!play?.on) return ok("play mode is off");
1780
+ await play.exit();
1781
+ return ok("play off");
1782
+ }
1783
+
1784
+ /**
1785
+ * One key in play mode. True when play mode consumed it; false sends it on
1786
+ * to the normal bindings (overlays, a command being typed, Ctrl-C, arrows).
1787
+ */
1788
+ function playKey(value: string): boolean {
1789
+ const session = play;
1790
+ if (!session?.on) return false;
1791
+ if (tui.ui.overlay !== undefined) return false;
1792
+ // `/play off`, `/click 50%`: once a command is being typed, keys are text.
1793
+ if (prompt.value.length > 0) return false;
1794
+ if (value === "/") return false;
1795
+ if (value === "\u0003") return false;
1796
+ const result = session.press(value);
1797
+ if (result.type === "handled") return true;
1798
+ if (result.type === "command") {
1799
+ if (result.command === "exit")
1800
+ void exitPlay().then((outcome) => receipt(outcome));
1801
+ else if (result.command === "transport") {
1802
+ if (!session.startWithCountIn())
1803
+ void toggleTransport().catch((error: unknown) =>
1804
+ tui.activity.pushError(
1805
+ `transport failed · ${error instanceof Error ? error.message : String(error)}`,
1806
+ ),
1807
+ );
1808
+ } else if (result.command === "menu") return false;
1809
+ return true;
1810
+ }
1811
+ // Unmapped: printable keys are swallowed so a stray letter never lands in
1812
+ // the prompt; control keys (Enter, arrows, Ctrl-Z) keep their bindings.
1813
+ return value.length === 1 && value >= " " && value !== "\u007f";
1814
+ }
1815
+
1816
+ function playHost() {
1817
+ return {
1818
+ score: () => score,
1819
+ trackId: () => requestedTrack,
1820
+ now: () => performance.now(),
1821
+ playing: () => clock.playing,
1822
+ beatAt: (ms: number) => clock.beatAt(ms),
1823
+ engine: liveEngine,
1824
+ samples: () => liveSampleBank,
1825
+ async commit(
1826
+ next: TrackScore,
1827
+ kind: string,
1828
+ payload: Record<string, unknown>,
1829
+ ): Promise<void> {
1830
+ const operations = (payload.operations ?? []) as ScoreOperation[];
1831
+ let target = next;
1832
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1833
+ try {
1834
+ record = await port.appendOperations(
1835
+ record,
1836
+ { kind, payload },
1837
+ operations,
1838
+ target.toJSON(),
1839
+ );
1840
+ score = scoreFromJSON(record.composition);
1841
+ if (clock.playing) void audio.play(score);
1842
+ projectSync?.scoreChanged(score);
1843
+ return;
1844
+ } catch (error) {
1845
+ if (!(error instanceof SessionConflictError)) throw error;
1846
+ record = await port.load();
1847
+ score = scoreFromJSON(record.composition);
1848
+ target = operations.reduce(
1849
+ (value, operation) => applyScoreOperation(value, operation),
1850
+ score,
1851
+ );
1852
+ }
1853
+ }
1854
+ throw new Error("session busy");
1855
+ },
1856
+ async startTransport(beat: number): Promise<void> {
1857
+ if (port.mode === "daemon") {
1858
+ await port.transport("play", { beat });
1859
+ return;
1860
+ }
1861
+ clock.sync(beat, false);
1862
+ await setTransport("play");
1863
+ },
1864
+ async stopTransport(): Promise<void> {
1865
+ await setTransport("pause");
1866
+ },
1867
+ card(text: string, tone: "info" | "success" | "warning" | "error") {
1868
+ if (tone === "error") tui.activity.pushError(text);
1869
+ else
1870
+ tui.activity.pushCard(text, {
1871
+ tone,
1872
+ trackId: requestedTrack,
1873
+ resultRevision: tone === "success" ? record.revision : undefined,
1874
+ });
1875
+ },
1876
+ newNoteId: () => randomUUID().slice(0, 12),
1877
+ };
1878
+ }
1879
+
1126
1880
  async function commitScore(
1127
1881
  next: TrackScore,
1128
1882
  kind: string,
1129
1883
  payload: Record<string, unknown> = {},
1130
1884
  ): Promise<void> {
1131
1885
  if (next === score) return;
1132
- record = await port.append(
1133
- record,
1134
- { kind, payload: { ...payload, before: record.composition } },
1135
- next.toJSON(),
1136
- );
1886
+ record = await port.append(record, { kind, payload }, next.toJSON());
1137
1887
  score = next;
1138
1888
  if (clock.playing) void audio.play(score);
1889
+ projectSync?.scoreChanged(score);
1890
+ reportSampleProblems(score);
1891
+ }
1892
+
1893
+ /** The window's side of the project file sync (see src/project/sync.ts). */
1894
+ function syncHost(): SyncHost {
1895
+ return {
1896
+ project: process.cwd(),
1897
+ current: () => score,
1898
+ async commit(plan, summary) {
1899
+ const baseRevision = record.revision;
1900
+ record = await port.appendOperations(
1901
+ record,
1902
+ {
1903
+ kind: "files.apply",
1904
+ payload: { summary },
1905
+ },
1906
+ plan.operations,
1907
+ plan.next.toJSON(),
1908
+ );
1909
+ score = scoreFromJSON(record.composition);
1910
+ clock.setTempo(score.tempoBpm);
1911
+ if (clock.playing) void audio.play(score);
1912
+ reportSampleProblems(score);
1913
+ void baseRevision;
1914
+ return score;
1915
+ },
1916
+ card(text, tone, hint) {
1917
+ if (tone === "error") tui.activity.pushError(text);
1918
+ else
1919
+ tui.activity.pushCard(text, {
1920
+ tone,
1921
+ hint,
1922
+ resultRevision: tone === "success" ? record.revision : undefined,
1923
+ });
1924
+ },
1925
+ types(state) {
1926
+ typesIndicator = state;
1927
+ },
1928
+ };
1139
1929
  }
1140
1930
 
1141
- async function stepHistory(direction: "undo" | "redo"): Promise<string> {
1931
+ async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
1142
1932
  const latest = await port.load();
1143
1933
  // A fork's undo continues into its parent's history past the fork point.
1144
1934
  const target = historyTarget(
1935
+ latest.composition,
1145
1936
  await historyEvents(process.cwd(), latest),
1146
1937
  direction,
1147
1938
  );
1148
- if (!target) return `nothing to ${direction}`;
1939
+ if (!target) return warn(`nothing to ${direction}`);
1149
1940
  try {
1150
1941
  const restored = scoreFromJSON(target.composition);
1151
1942
  record = await port.append(
@@ -1155,18 +1946,46 @@ async function stepHistory(direction: "undo" | "redo"): Promise<string> {
1155
1946
  payload: {
1156
1947
  [direction === "undo" ? "undoneRevision" : "redoneRevision"]:
1157
1948
  target.revision,
1158
- before: latest.composition,
1159
1949
  },
1160
1950
  },
1161
1951
  restored.toJSON(),
1162
1952
  );
1163
1953
  score = restored;
1164
1954
  if (clock.playing) void audio.play(score);
1165
- return `${direction === "undo" ? "undid" : "redid"} · rev ${target.revision}`;
1955
+ return ok(
1956
+ `${direction === "undo" ? "undid" : "redid"} · rev ${target.revision}`,
1957
+ );
1166
1958
  } catch (error) {
1167
1959
  if (error instanceof SessionConflictError)
1168
- return `session changed; retry ${direction}`;
1169
- return `${direction} error · ${error instanceof Error ? error.message : String(error)}`;
1960
+ return warn(`session changed · retry ${direction}`);
1961
+ return fail(
1962
+ `${direction} failed · ${error instanceof Error ? error.message : String(error)}`,
1963
+ );
1964
+ }
1965
+ }
1966
+
1967
+ /** The space-bar toggle: flip the transport, then record it for other windows. */
1968
+ async function toggleTransport(): Promise<void> {
1969
+ await setTransport("toggle");
1970
+ try {
1971
+ record = await port.append(
1972
+ record,
1973
+ {
1974
+ kind: "transport",
1975
+ payload: {
1976
+ action: "toggle",
1977
+ playing: clock.playing,
1978
+ beat: clock.beatAt(),
1979
+ },
1980
+ },
1981
+ score.toJSON(),
1982
+ );
1983
+ } catch (error) {
1984
+ if (error instanceof SessionConflictError)
1985
+ tui.activity.pushCard("transport changed in another window", {
1986
+ tone: "warning",
1987
+ });
1988
+ else throw error;
1170
1989
  }
1171
1990
  }
1172
1991
 
@@ -1198,11 +2017,13 @@ async function setTransport(
1198
2017
  * own revision through `agentHost`, so cancelling keeps every accepted change
1199
2018
  * and never leaves a half-applied call.
1200
2019
  */
1201
- async function runAgent(text: string): Promise<string> {
1202
- if (agentTurn) return "agent busy";
2020
+ async function runAgent(text: string): Promise<string | Receipt> {
2021
+ if (agentTurn) return warn("agent busy · Esc cancels");
1203
2022
  const selection = await currentProvider();
1204
2023
  if (selection.kind === "offline")
1205
- return `unrecognized request · ${selection.reason}`;
2024
+ return fail(
2025
+ `unrecognized · ${truncateForCard(text)} · ${selection.reason}`,
2026
+ );
1206
2027
  const turn = { controller: new AbortController(), steering: [] as string[] };
1207
2028
  agentTurn = turn;
1208
2029
  reportAgentActivity(`${providerName} · thinking…`);
@@ -1213,8 +2034,7 @@ async function runAgent(text: string): Promise<string> {
1213
2034
  const result = await runProviderTurn({
1214
2035
  selection,
1215
2036
  prompt: text,
1216
- model: selectedModel,
1217
- host: agentHost(turn),
2037
+ host: agentHost(turn, selection),
1218
2038
  signal: turn.controller.signal,
1219
2039
  onEvent: (event) => {
1220
2040
  if (admitting && event.type === "step" && event.step <= 1) return;
@@ -1222,6 +2042,22 @@ async function runAgent(text: string): Promise<string> {
1222
2042
  admitting = false;
1223
2043
  provider = undefined; // Re-read capabilities: now admitted.
1224
2044
  }
2045
+ if (event.type === "usage") {
2046
+ // Subscriptions are included; API responses are priced once.
2047
+ if (isApiSelection(selection)) {
2048
+ const { type: _type, ...usage } = event;
2049
+ void priceForModel(selection.kind, selection.modelId, {
2050
+ configDir: configDir(),
2051
+ apiKey: selection.apiKey,
2052
+ })
2053
+ .catch(() => undefined)
2054
+ .then((price) => {
2055
+ meter.add(usage, price);
2056
+ tickUi();
2057
+ });
2058
+ }
2059
+ return;
2060
+ }
1225
2061
  agentEventSink(event);
1226
2062
  },
1227
2063
  });
@@ -1257,13 +2093,33 @@ function currentProvider(): Promise<ProviderSelection> {
1257
2093
  }));
1258
2094
  }
1259
2095
  return provider.then((selection) => {
1260
- providerName = providerLabel(selection, selectedModel);
2096
+ providerSnapshot = selection;
2097
+ providerName = providerLabel(selection);
2098
+ if (
2099
+ selection.kind === "offline" &&
2100
+ selection.invalidSaved &&
2101
+ !invalidNoticeShown
2102
+ ) {
2103
+ invalidNoticeShown = true;
2104
+ tui.activity.pushCard(`sign-in stopped working · ${selection.reason}`, {
2105
+ tone: "warning",
2106
+ hint: "/login",
2107
+ });
2108
+ }
1261
2109
  providerStamp.offline = selection.kind === "offline";
1262
2110
  return selection;
1263
2111
  });
1264
2112
  }
1265
2113
 
1266
- function agentHost(turn: { steering: string[] }): AgentHost {
2114
+ /** Media tools write under the workspace's `tracks/<slug>/downloads/`. */
2115
+ function mediaServices(): MediaServices {
2116
+ return { runner: systemRunner, env: process.env };
2117
+ }
2118
+
2119
+ function agentHost(
2120
+ turn: { steering: string[] },
2121
+ selection: ProviderSelection,
2122
+ ): AgentHost {
1267
2123
  return {
1268
2124
  snapshot: () => ({
1269
2125
  score,
@@ -1276,6 +2132,7 @@ function agentHost(turn: { steering: string[] }): AgentHost {
1276
2132
  : event.kind;
1277
2133
  }),
1278
2134
  }),
2135
+ media: mediaServices(),
1279
2136
  async commit(change) {
1280
2137
  // dawgd rebases operation intents onto newer revisions when nothing
1281
2138
  // they touch changed; the file port keeps the strict base check.
@@ -1291,7 +2148,6 @@ function agentHost(turn: { steering: string[] }): AgentHost {
1291
2148
  callId: change.callId,
1292
2149
  summary: change.summary,
1293
2150
  operations: change.operations,
1294
- before: record.composition,
1295
2151
  },
1296
2152
  },
1297
2153
  change.operations,
@@ -1328,5 +2184,26 @@ function agentHost(turn: { steering: string[] }): AgentHost {
1328
2184
  }
1329
2185
  },
1330
2186
  takeSteering: () => turn.steering.splice(0),
2187
+ workspace: { root: process.cwd() },
2188
+ // A written project source is applied before the tool result returns,
2189
+ // so the model reads the outcome and any type errors in the same step.
2190
+ async onWorkspaceWrite(path) {
2191
+ if (!projectSync || !/\.ts$/.test(path)) return;
2192
+ const outcome = await projectSync.checkFiles();
2193
+ const types = await typecheckProject(process.cwd());
2194
+ const errors = types.diagnostics.slice(0, 8).map(formatDiagnostic);
2195
+ return [
2196
+ outcome,
2197
+ types.ok ? "types ✓" : `types ✗ ${types.diagnostics.length}`,
2198
+ ...errors,
2199
+ ].join("\n");
2200
+ },
2201
+ // web_search uses the turn's own gateway or OpenRouter key; billed
2202
+ // searches go to the spend meter and daily ledger.
2203
+ web: webHostFor(
2204
+ isApiSelection(selection) ? selection : { kind: selection.kind },
2205
+ meter,
2206
+ tickUi,
2207
+ ),
1331
2208
  };
1332
2209
  }