@pinkpixel/haku 0.1.0 → 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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ The `haku` command gives you (or your agent) a tool for every drawing command, p
11
11
  - Node 22.18 or newer
12
12
  - For listening without the browser: one of `pw-play`, `paplay`, `aplay`, `afplay`, or `ffplay` (Windows uses PowerShell, so nothing extra there)
13
13
 
14
- The package is about 7 MB, mostly because the instrument samples come with it.
14
+ The package is about 9 MB, mostly because the instrument samples come with it. The web app is in there too.
15
15
 
16
16
  ## Install
17
17
 
@@ -102,21 +102,23 @@ Every drawing and editing command is its own tool (`draw_line`, `set_mood`, `add
102
102
 
103
103
  ## Working live in the browser
104
104
 
105
- Haku also has a web app with the actual drawing canvas. When it's open, leave out `-f` and the tools work on that tab instead of a file, so you see and hear every change, and Ctrl+Z still undoes it. The MCP server hosts a small bridge on `127.0.0.1:5179` for this, and the tab connects on its own. Without an MCP server, run `haku bridge` in another terminal.
106
-
107
- Right now the web app isn't hosted anywhere, so live mode means running it from the repo:
105
+ Haku also has a web app with the actual drawing canvas, and it ships in this package. Open it with:
108
106
 
109
107
  ```bash
110
- git clone https://github.com/pinkpixel-dev/haku.git
111
- cd haku
112
- pnpm install
113
- pnpm --filter @haku/web dev
108
+ haku web
114
109
  ```
115
110
 
116
- Then open http://localhost:5178. A few things to know:
111
+ That serves the app on http://127.0.0.1:5177, hosts the agent bridge, and opens your default browser. Leave out `-f` and the tools work on that tab instead of a file, so you see and hear every change, and Ctrl+Z still undoes it. The MCP server also hosts the bridge on `127.0.0.1:5179`, and the tab connects to whichever one is running on its own.
112
+
113
+ You don't have to open it first. When a tool needs the tab and none is connected, Haku starts `haku web` in the background (it quits after 30 minutes with no tab connected), opens the browser, and waits for the tab. Set `HAKU_NO_LAUNCH=1` if you'd rather get an error instead.
114
+
115
+ In Chrome or Edge you can install Haku as an app from the install icon in the address bar. It gets its own window, and since the app and samples are cached after the first visit, it opens fine even when `haku web` isn't running. Agents only reach it while a bridge is up, though, so start `haku web` or your MCP client first if you want that.
116
+
117
+ A few things to know:
117
118
 
118
119
  - Browsers won't let a page make sound until you've clicked in it once. If `play` says so, click anywhere in the tab and try again.
119
120
  - With more than one Haku tab open, tools drive the one you clicked into most recently.
121
+ - Your projects are saved in the browser for `127.0.0.1:5177`. Changing `HAKU_WEB_PORT` gives you a separate, empty project list, so use the folder button to export and import if you move.
120
122
  - The bridge only accepts tabs from `localhost` pages and only accepts tools from outside the browser. `HAKU_ALLOW_ORIGINS` (comma separated) adds other page origins, and `HAKU_BRIDGE_PORT` changes the port.
121
123
 
122
124
  ## Environment variables
@@ -125,6 +127,8 @@ Then open http://localhost:5178. A few things to know:
125
127
  |---|---|
126
128
  | `HAKU_PROJECT` | Default project file for commands that take `-f` |
127
129
  | `HAKU_BRIDGE_PORT` | Bridge port. Default `5179` |
130
+ | `HAKU_WEB_PORT` | Port `haku web` serves the app on. Default `5177` |
131
+ | `HAKU_NO_LAUNCH` | Set it to stop tools from opening Haku when no tab is connected |
128
132
  | `HAKU_ALLOW_ORIGINS` | Extra page origins allowed to connect as a Haku tab |
129
133
  | `HAKU_SAMPLES` | Use a different sample folder (one with a `manifest.json`) |
130
134
 
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as BridgeClient, c as bridgePort, i as GUIDE, n as TOOLS, o as BridgeHost, r as Player, t as VERSION } from "./version-DoZ2etXG.mjs";
2
+ import { a as webRunning, c as Player, d as BridgeHost, f as SharedLink, i as webPort, l as GUIDE, n as openBrowser, o as webUrl, p as bridgePort, r as serveWeb, s as VERSION, t as TOOLS, u as BridgeClient } from "./tools-_xkF2Yfv.mjs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { resolve } from "node:path";
5
5
  import { Command, Option } from "commander";
@@ -8,10 +8,11 @@ import { z } from "zod";
8
8
  const kebab = (s) => s.replace(/_/g, "-");
9
9
  let client = null;
10
10
  const ctx = {
11
- link: () => client ??= BridgeClient.connect(),
11
+ link: async () => await client ?? await (client = BridgeClient.connect()),
12
12
  player: new Player(),
13
13
  waitForPlayback: true,
14
- cwd: process.env.INIT_CWD ?? process.cwd()
14
+ cwd: process.env.INIT_CWD ?? process.cwd(),
15
+ autoLaunch: !process.env.HAKU_NO_LAUNCH
15
16
  };
16
17
  function fail(message) {
17
18
  console.error(`haku: ${message}`);
@@ -110,6 +111,39 @@ program.command("exec").argument("[commands]", "JSON file of commands (an array,
110
111
  }
111
112
  await runTool(batch, parsed.data);
112
113
  });
114
+ /** How long an auto-launched `haku web` keeps running with no tab connected. */
115
+ const IDLE_MS = 18e5;
116
+ program.command("web").description("Open Haku in your browser. Serves the app on localhost and hosts the bridge, so agents can drive it too.").option("--no-open", "Just serve, don't open a browser").addOption(new Option("--exit-when-idle").hideHelp()).action(async (opts) => {
117
+ const port = webPort();
118
+ const url = webUrl(port);
119
+ let server;
120
+ try {
121
+ server = await serveWeb(port);
122
+ } catch (err) {
123
+ if (err.code !== "EADDRINUSE") fail(err instanceof Error ? err.message : String(err));
124
+ if (!await webRunning(port)) fail(`port ${port} is taken by something else. Set HAKU_WEB_PORT to use another one.`);
125
+ console.log(`Haku is already running at ${url}`);
126
+ if (opts.open) await openBrowser(url);
127
+ return;
128
+ }
129
+ const link = new SharedLink();
130
+ await link.ensure().catch(() => null);
131
+ console.log(`Haku is running at ${url}. Ctrl+C to stop.`);
132
+ if (opts.open && !await openBrowser(url)) console.log(`Couldn't open a browser. Open ${url} yourself.`);
133
+ const shutdown = () => {
134
+ server.close();
135
+ link.close().finally(() => process.exit(0));
136
+ };
137
+ process.once("SIGINT", shutdown);
138
+ process.once("SIGTERM", shutdown);
139
+ if (opts.exitWhenIdle) {
140
+ let lastTab = Date.now();
141
+ setInterval(async () => {
142
+ if ((await link.request({ op: "ping" }).catch(() => null))?.tab) lastTab = Date.now();
143
+ else if (Date.now() - lastTab > IDLE_MS) shutdown();
144
+ }, 6e4);
145
+ }
146
+ });
113
147
  program.command("bridge").description("Run the localhost bridge on its own, so the CLI can reach the open Haku tab.").action(async () => {
114
148
  const port = bridgePort();
115
149
  let host;
@@ -124,7 +158,7 @@ program.command("bridge").description("Run the localhost bridge on its own, so t
124
158
  process.once("SIGINT", () => void host.close().then(() => process.exit(0)));
125
159
  });
126
160
  program.command("mcp").description("Start the MCP server on stdio. It also hosts the bridge to the Haku tab.").action(async () => {
127
- const { startMcp } = await import("./mcp-DDuNECfs.mjs");
161
+ const { startMcp } = await import("./mcp-DG9U-P0N.mjs");
128
162
  await startMcp();
129
163
  });
130
164
  const argv = [...process.argv];
@@ -1,4 +1,4 @@
1
- import { i as GUIDE, n as TOOLS, r as Player, s as SharedLink, t as VERSION } from "./version-DoZ2etXG.mjs";
1
+ import { c as Player, f as SharedLink, l as GUIDE, s as VERSION, t as TOOLS } from "./tools-_xkF2Yfv.mjs";
2
2
  import { McpServer } from "@modelcontextprotocol/server";
3
3
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
4
4
  // src/mcp.ts
@@ -17,7 +17,8 @@ async function startMcp() {
17
17
  link: () => link.ensure().catch(() => null),
18
18
  player,
19
19
  waitForPlayback: false,
20
- cwd: process.cwd()
20
+ cwd: process.cwd(),
21
+ autoLaunch: !process.env.HAKU_NO_LAUNCH
21
22
  };
22
23
  const handle = serveStdio(() => {
23
24
  const server = new McpServer({
@@ -1,4 +1,4 @@
1
- import { S as DRUM_PIECE_IDS, T as INSTRUMENTS, i as buildSceneScore, p as createRng, r as buildArrangementScore } from "./src-D5fW7AC1.mjs";
1
+ import { S as DRUM_PIECE_IDS, T as INSTRUMENTS, i as buildSceneScore, p as createRng, r as buildArrangementScore } from "./src-dP0jXhPP.mjs";
2
2
  import * as Tone from "tone";
3
3
  // ../../packages/core/src/engine/types.ts
4
4
  function beatsToSeconds(beats, bpm) {
@@ -550,7 +550,17 @@ function loopScore(score, loops) {
550
550
  function scoreFor(project, opts = {}) {
551
551
  return opts.scope === "song" ? buildArrangementScore(project) : loopScore(buildSceneScore(project, opts.sceneId), opts.loops ?? 1);
552
552
  }
553
- async function renderProject(project, bank, opts = {}) {
553
+ /**
554
+ * Tone.Offline swaps Tone's global context while it runs, so two overlapping renders would build
555
+ * nodes on each other's context. Renders wait their turn instead.
556
+ */
557
+ let queue = Promise.resolve();
558
+ function renderProject(project, bank, opts = {}) {
559
+ const run = queue.then(() => renderNow(project, bank, opts));
560
+ queue = run.catch(() => {});
561
+ return run;
562
+ }
563
+ async function renderNow(project, bank, opts) {
554
564
  const score = scoreFor(project, opts);
555
565
  const sampleRate = opts.sampleRate ?? 44100;
556
566
  const seconds = LEAD_IN + beatsToSeconds(score.beats, score.bpm) + (opts.tail ?? 3);
@@ -1412,11 +1412,20 @@ function beatStrength(beat) {
1412
1412
  if (Math.abs(inBar - Math.round(inBar)) > 1e-6) return "weak";
1413
1413
  return Math.round(inBar) % 2 === 0 ? "strong" : "medium";
1414
1414
  }
1415
+ /**
1416
+ * The key's scale bent to fit the chord. When a chord brings in a note from outside the key (G# in
1417
+ * E7 over A minor), scale tones a half step from it drop out, so G and G# never both show up.
1418
+ * Chord tones always stay.
1419
+ */
1420
+ function fittedScale(scale, chord) {
1421
+ const outside = chord.filter((pc) => !scale.includes(pc));
1422
+ return scale.filter((pc) => chord.includes(pc) || !outside.some((o) => mod12(pc - o) === 1 || mod12(o - pc) === 1));
1423
+ }
1415
1424
  /** The pitch classes a note is allowed to land on in this context. */
1416
1425
  function allowedPcs(ctx) {
1417
1426
  const tonic = tonicPc(ctx.key);
1418
- const scale = scalePcs(ctx.key);
1419
1427
  const chord = chordPcs(mod12(tonic + ctx.chord.root), ctx.chord.quality);
1428
+ const scale = ctx.mode === "smart" ? fittedScale(scalePcs(ctx.key), chord) : scalePcs(ctx.key);
1420
1429
  switch (ctx.mode) {
1421
1430
  case "chromatic": return [
1422
1431
  0,
@@ -1,13 +1,14 @@
1
- import { A as PROGRESSIONS, C as GROOVE_IDS, D as RAIN_KINDS, E as INSTRUMENT_IDS, F as prefersFlats, I as scalePcs, L as mod12, M as resolveProgression, N as MODES, O as WINDOW_SCENES, P as keyLabel, R as noteName, T as INSTRUMENTS, _ as BASS_STYLES, a as layerRange, b as COMP_STYLES, c as createProject, d as findScene, f as parseProject, g as getMood, h as MOOD_IDS, i as buildSceneScore, j as progressionText, k as drumRows, l as createScene, m as makeId, n as describeProject, o as pitchToY, r as buildArrangementScore, s as createLayer, t as scoreToMidi, u as findLayer, v as BRUSH_IDS, w as HARMONY_MODES, x as DRUM_KITS, y as CHORD_INSTRUMENTS, z as parseNote } from "./src-D5fW7AC1.mjs";
2
- import { readFile, rename, writeFile } from "node:fs/promises";
3
- import { delimiter, join, resolve } from "node:path";
1
+ import { A as PROGRESSIONS, C as GROOVE_IDS, D as RAIN_KINDS, E as INSTRUMENT_IDS, F as prefersFlats, I as scalePcs, L as mod12, M as resolveProgression, N as MODES, O as WINDOW_SCENES, P as keyLabel, R as noteName, T as INSTRUMENTS, _ as BASS_STYLES, a as layerRange, b as COMP_STYLES, c as createProject, d as findScene, f as parseProject, g as getMood, h as MOOD_IDS, i as buildSceneScore, j as progressionText, k as drumRows, l as createScene, m as makeId, n as describeProject, o as pitchToY, r as buildArrangementScore, s as createLayer, t as scoreToMidi, u as findLayer, v as BRUSH_IDS, w as HARMONY_MODES, x as DRUM_KITS, y as CHORD_INSTRUMENTS, z as parseNote } from "./src-dP0jXhPP.mjs";
2
+ import { readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { delimiter, extname, join, normalize, resolve, sep } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { WebSocket, WebSocketServer } from "ws";
7
7
  import { spawn } from "node:child_process";
8
- import { existsSync } from "node:fs";
8
+ import { createReadStream, existsSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { createServer } from "node:http";
11
12
  // ../../packages/core/src/shapes.ts
12
13
  const SHAPES = [
13
14
  "wave",
@@ -714,7 +715,7 @@ function parseBridgeMessage(raw) {
714
715
  return parsed.success ? parsed.data : null;
715
716
  }
716
717
  // src/bridge.ts
717
- const NO_TAB = "No Haku tab is connected. Open Haku in a browser (it connects on its own), or pass a project file.";
718
+ const NO_TAB = "No Haku tab is connected. Run \"haku web\" to open one, or pass a project file.";
718
719
  const DEFAULT_TIMEOUT = 2e4;
719
720
  function bridgePort() {
720
721
  const env = Number(process.env.HAKU_BRIDGE_PORT);
@@ -948,7 +949,7 @@ const presets = Object.entries(PROGRESSIONS).map(([id, p]) => `${id} (${p.text})
948
949
  const GUIDE = `Haku turns strokes on a canvas into lofi music. A backing band (chords, bass, drums) plays along, and drawn notes snap to the chords.
949
950
 
950
951
  Where the work happens:
951
- - With no file argument, tools act on the project open in the Haku browser tab, live. Changes show up there and land in its undo history.
952
+ - With no file argument, tools act on the project open in the Haku browser tab, live. Changes show up there and land in its undo history. If no tab is open, Haku starts its local app server and opens one in the browser.
952
953
  - With file set to a .haku.json path, tools read and write that file instead. new_project makes one.
953
954
 
954
955
  The canvas:
@@ -1090,7 +1091,10 @@ function analyzeScenes(project, sceneIds) {
1090
1091
  ...base,
1091
1092
  range: notes.length ? `${noteName(Math.min(...pitches), flats)} to ${noteName(Math.max(...pitches), flats)}` : "none",
1092
1093
  strongBeatChordTones: strong.length ? round(chordTones / strong.length) : 1,
1093
- outOfKey: notes.filter((e) => !scale.has(mod12(e.midi))).length
1094
+ outOfKey: notes.filter((e) => {
1095
+ const pc = mod12(e.midi);
1096
+ return !scale.has(pc) && !chordAt(Math.round(e.time * 4) / 4)?.pcs.includes(pc);
1097
+ }).length
1094
1098
  };
1095
1099
  });
1096
1100
  const tonal = new Set(scene.layers.filter((l) => INSTRUMENTS[l.instrument].kind === "tonal").map((l) => l.id));
@@ -1101,8 +1105,8 @@ function analyzeScenes(project, sceneIds) {
1101
1105
  const b = drawn[j];
1102
1106
  if (a.track === b.track) continue;
1103
1107
  const overlap = Math.min(a.time + a.duration, b.time + b.duration) - Math.max(a.time, b.time);
1104
- const interval = mod12(a.midi - b.midi);
1105
- if (overlap > .1 && (interval === 1 || interval === 11)) {
1108
+ const gap = Math.abs(a.midi - b.midi);
1109
+ if (overlap > .1 && (gap === 1 || gap === 13)) {
1106
1110
  const beat = round(Math.max(a.time, b.time), 2);
1107
1111
  if (!clashes.has(beat)) clashes.set(beat, `${noteName(a.midi, flats)} against ${noteName(b.midi, flats)}`);
1108
1112
  }
@@ -1159,13 +1163,20 @@ function fontFiles() {
1159
1163
  const dir = [new URL("./fonts", import.meta.url), new URL("../assets/fonts", import.meta.url)].map((u) => fileURLToPath(u)).find((d) => existsSync(join(d, "Figtree-Regular.ttf")));
1160
1164
  return dir ? ["Figtree-Regular.ttf", "Figtree-SemiBold.ttf"].map((f) => join(dir, f)) : [];
1161
1165
  }
1166
+ /** The built web app. A published build carries it in dist/web; in the repo it's apps/web/dist (run `pnpm --filter @haku/web build`). */
1167
+ function webDir() {
1168
+ if (process.env.HAKU_WEB_DIR) return resolve(process.env.HAKU_WEB_DIR);
1169
+ const found = [new URL("./web", import.meta.url), new URL("../../web/dist", import.meta.url)].map((u) => fileURLToPath(u)).find((dir) => existsSync(join(dir, "index.html")));
1170
+ if (!found) throw new Error("Can't find the built Haku app. In the repo, run `pnpm --filter @haku/web build` first.");
1171
+ return found;
1172
+ }
1162
1173
  // src/audio.ts
1163
1174
  let kit = null;
1164
1175
  function loadAudio() {
1165
1176
  kit ??= (async () => {
1166
1177
  const node = await import("./node-CVxAljK8.mjs");
1167
1178
  node.setupNodeAudio();
1168
- const audio = await import("./src-JrRbTOJC.mjs");
1179
+ const audio = await import("./src-CgZeiBJc.mjs");
1169
1180
  const dir = samplesDir();
1170
1181
  const bank = await audio.loadSampleBank(await node.readManifest(dir), node.nodeDecoder(dir));
1171
1182
  return {
@@ -1181,6 +1192,149 @@ function loadAudio() {
1181
1192
  kit.catch(() => kit = null);
1182
1193
  return kit;
1183
1194
  }
1195
+ // src/version.ts
1196
+ const VERSION = "0.2.0";
1197
+ // src/web.ts
1198
+ const WEB_HOST = "127.0.0.1";
1199
+ const WEB_PORT = 5177;
1200
+ function webPort() {
1201
+ const env = Number(process.env.HAKU_WEB_PORT);
1202
+ return Number.isInteger(env) && env > 0 ? env : WEB_PORT;
1203
+ }
1204
+ /** 127.0.0.1 rather than localhost: projects are stored per origin, so the address has to stay the same. */
1205
+ const webUrl = (port = webPort()) => `http://${WEB_HOST}:${port}/`;
1206
+ const TYPES = {
1207
+ ".html": "text/html; charset=utf-8",
1208
+ ".js": "text/javascript; charset=utf-8",
1209
+ ".css": "text/css; charset=utf-8",
1210
+ ".json": "application/json",
1211
+ ".webmanifest": "application/manifest+json",
1212
+ ".png": "image/png",
1213
+ ".svg": "image/svg+xml",
1214
+ ".woff2": "font/woff2",
1215
+ ".ogg": "audio/ogg",
1216
+ ".flac": "audio/flac",
1217
+ ".wav": "audio/wav"
1218
+ };
1219
+ /** Resolves a URL path inside root, or null if it tries to climb out. */
1220
+ function inside(root, urlPath) {
1221
+ const full = normalize(join(root, urlPath));
1222
+ return full === root || full.startsWith(root + sep) ? full : null;
1223
+ }
1224
+ async function findFile(roots, urlPath) {
1225
+ for (const root of roots) {
1226
+ const full = inside(root, urlPath);
1227
+ if (!full) continue;
1228
+ if ((await stat(full).catch(() => null))?.isFile()) return full;
1229
+ }
1230
+ return null;
1231
+ }
1232
+ /** Starts serving. Rejects with code EADDRINUSE when the port is taken. */
1233
+ function serveWeb(port = webPort()) {
1234
+ const roots = [webDir(), samplesDir()];
1235
+ const server = createServer(async (req, res) => {
1236
+ if (req.method !== "GET" && req.method !== "HEAD") {
1237
+ res.writeHead(405).end();
1238
+ return;
1239
+ }
1240
+ let path;
1241
+ try {
1242
+ path = decodeURIComponent(new URL(req.url ?? "/", "http://haku").pathname);
1243
+ } catch {
1244
+ res.writeHead(400).end();
1245
+ return;
1246
+ }
1247
+ if (path === "/__haku") {
1248
+ res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({
1249
+ haku: true,
1250
+ version: VERSION
1251
+ }));
1252
+ return;
1253
+ }
1254
+ const file = await findFile(roots, path === "/" ? "/index.html" : path);
1255
+ if (!file) {
1256
+ res.writeHead(404, { "Content-Type": "text/plain" }).end("Not found");
1257
+ return;
1258
+ }
1259
+ res.writeHead(200, {
1260
+ "Content-Type": TYPES[extname(file)] ?? "application/octet-stream",
1261
+ "Cache-Control": path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"
1262
+ });
1263
+ if (req.method === "HEAD") res.end();
1264
+ else createReadStream(file).pipe(res);
1265
+ });
1266
+ return new Promise((resolve, reject) => {
1267
+ server.once("error", reject);
1268
+ server.listen(port, WEB_HOST, () => resolve(server));
1269
+ });
1270
+ }
1271
+ /** True when a haku web server already answers on the port. */
1272
+ async function webRunning(port = webPort()) {
1273
+ try {
1274
+ const res = await fetch(`${webUrl(port)}__haku`, { signal: AbortSignal.timeout(1e3) });
1275
+ return res.ok && (await res.json()).haku === true;
1276
+ } catch {
1277
+ return false;
1278
+ }
1279
+ }
1280
+ /** Opens a URL in the default browser. Resolves false when no opener could be started. */
1281
+ function openBrowser(url) {
1282
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["explorer.exe", [url]] : ["xdg-open", [url]];
1283
+ return new Promise((resolve) => {
1284
+ const child = spawn(cmd, args, {
1285
+ stdio: "ignore",
1286
+ detached: true
1287
+ });
1288
+ child.once("error", () => resolve(false));
1289
+ child.once("spawn", () => {
1290
+ child.unref();
1291
+ resolve(true);
1292
+ });
1293
+ });
1294
+ }
1295
+ // src/launch.ts
1296
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1297
+ /** Polls until check returns something truthy, or gives up with null. */
1298
+ async function waitFor(check, timeoutMs) {
1299
+ const end = Date.now() + timeoutMs;
1300
+ for (;;) {
1301
+ const value = await check();
1302
+ if (value) return value;
1303
+ if (Date.now() >= end) return null;
1304
+ await sleep(400);
1305
+ }
1306
+ }
1307
+ async function tabOn(getLink) {
1308
+ try {
1309
+ const link = await getLink();
1310
+ if (!link) return null;
1311
+ return (await link.request({ op: "ping" })).tab ? link : null;
1312
+ } catch {
1313
+ return null;
1314
+ }
1315
+ }
1316
+ async function launchTab(getLink) {
1317
+ const port = webPort();
1318
+ const url = webUrl(port);
1319
+ if (!await webRunning(port)) {
1320
+ spawn(process.execPath, [
1321
+ process.argv[1],
1322
+ "web",
1323
+ "--no-open",
1324
+ "--exit-when-idle"
1325
+ ], {
1326
+ detached: true,
1327
+ stdio: "ignore"
1328
+ }).unref();
1329
+ if (!await waitFor(() => webRunning(port), 1e4)) throw new Error(`Couldn't start haku web on port ${port}. Run "haku web" yourself to see what went wrong.`);
1330
+ }
1331
+ const existing = await waitFor(() => tabOn(getLink), 3e3);
1332
+ if (existing) return existing;
1333
+ const opened = await openBrowser(url);
1334
+ const fresh = await waitFor(() => tabOn(getLink), 45e3);
1335
+ if (fresh) return fresh;
1336
+ throw new Error(opened ? `Opened Haku at ${url}, but no tab connected yet. Check the browser, then try again.` : `No Haku tab is connected and no browser could be opened. Open ${url} yourself, then try again.`);
1337
+ }
1184
1338
  // src/snapshot.ts
1185
1339
  /** The web app's walnut palette (tokens.css), as hex because resvg doesn't read oklch. */
1186
1340
  const C = {
@@ -1415,13 +1569,17 @@ async function connectedTab(ctx) {
1415
1569
  if (!link) return null;
1416
1570
  return (await link.request({ op: "ping" })).tab ? link : null;
1417
1571
  }
1572
+ /** The connected tab, opening Haku first when none is connected (unless auto-launch is off). */
1573
+ async function requireTab(ctx) {
1574
+ const tab = await connectedTab(ctx);
1575
+ if (tab) return tab;
1576
+ if (!ctx.autoLaunch) throw new Error(NO_TAB);
1577
+ return launchTab(ctx.link);
1578
+ }
1418
1579
  /** The file when one is given, otherwise the live tab. */
1419
1580
  async function targetFor(ctx, path) {
1420
1581
  if (path) return new FileTarget(resolve(ctx.cwd, path));
1421
- const link = await ctx.link();
1422
- if (!link) throw new Error("No project file given, and the Haku bridge isn't running. Pass a file, or start \"haku bridge\" (or the MCP server) and open Haku.");
1423
- if (!await connectedTab(ctx)) throw new Error(NO_TAB);
1424
- return new LiveTarget(link);
1582
+ return new LiveTarget(await requireTab(ctx));
1425
1583
  }
1426
1584
  /** The scene a tool works on, resolved to an id so file and live targets agree. */
1427
1585
  const sceneOf = (project, scene) => findScene(project, scene);
@@ -1483,9 +1641,7 @@ const newProject = define({
1483
1641
  await writeProjectFile(full, project);
1484
1642
  return { text: `Created "${project.name}" (${project.mood}) at ${full}` };
1485
1643
  }
1486
- const tab = await connectedTab(ctx);
1487
- if (!tab) throw new Error(NO_TAB);
1488
- await tab.request({
1644
+ await (await requireTab(ctx)).request({
1489
1645
  op: "open_project",
1490
1646
  project
1491
1647
  });
@@ -1500,9 +1656,7 @@ const openProject = define({
1500
1656
  async run({ file: path }, ctx) {
1501
1657
  const full = resolve(ctx.cwd, path);
1502
1658
  const project = await readProjectFile(full);
1503
- const tab = await connectedTab(ctx);
1504
- if (!tab) throw new Error(NO_TAB);
1505
- await tab.request({
1659
+ await (await requireTab(ctx)).request({
1506
1660
  op: "open_project",
1507
1661
  project
1508
1662
  });
@@ -1530,7 +1684,7 @@ const saveProject = define({
1530
1684
  const play = define({
1531
1685
  name: "play",
1532
1686
  title: "Play",
1533
- description: "Listen. With the Haku tab connected (and no file), it plays there. Otherwise it renders offline and plays through this machine's speakers.",
1687
+ description: "Listen. With no file it plays in the Haku tab, opening one if needed. With a file it renders offline and plays through this machine's speakers.",
1534
1688
  input: z.object({
1535
1689
  file,
1536
1690
  scope,
@@ -1539,17 +1693,14 @@ const play = define({
1539
1693
  }),
1540
1694
  async run({ file: path, scope = "scene", scene, loops = 2 }, ctx) {
1541
1695
  if (!path) {
1542
- const tab = await connectedTab(ctx);
1543
- if (tab) {
1544
- const status = await tab.request({
1545
- op: "play",
1546
- mode: scope === "song" ? "song" : "loop",
1547
- scene
1548
- });
1549
- return { text: `Playing ${scope === "song" ? "the song" : `scene ${status.sceneName} on a loop`} in the Haku tab` };
1550
- }
1696
+ const status = await (await requireTab(ctx)).request({
1697
+ op: "play",
1698
+ mode: scope === "song" ? "song" : "loop",
1699
+ scene
1700
+ });
1701
+ return { text: `Playing ${scope === "song" ? "the song" : `scene ${status.sceneName} on a loop`} in the Haku tab` };
1551
1702
  }
1552
- const project = await (await targetFor(ctx, path)).read();
1703
+ const project = await new FileTarget(resolve(ctx.cwd, path)).read();
1553
1704
  const sceneId = sceneOf(project, scene).id;
1554
1705
  const audio = await loadAudio();
1555
1706
  const pcm = await audio.render(project, scope === "song" ? { scope: "song" } : {
@@ -1710,7 +1861,7 @@ const status = define({
1710
1861
  async run(_, ctx) {
1711
1862
  const lines = [];
1712
1863
  const link = await ctx.link();
1713
- if (!link) lines.push("Bridge: not running. Start \"haku bridge\" or the MCP server, then open Haku.");
1864
+ if (!link) lines.push("Bridge: not running. Run \"haku web\" to open Haku with the bridge.");
1714
1865
  else {
1715
1866
  const ping = await link.request({ op: "ping" });
1716
1867
  if (!ping.tab) lines.push("Bridge: running, no Haku tab connected.");
@@ -1737,6 +1888,4 @@ const TOOLS = [
1737
1888
  exportTool,
1738
1889
  status
1739
1890
  ];
1740
- // src/version.ts
1741
- const VERSION = "0.1.0";
1742
- export { BridgeClient as a, bridgePort as c, GUIDE as i, TOOLS as n, BridgeHost as o, Player as r, SharedLink as s, VERSION as t };
1891
+ export { webRunning as a, Player as c, BridgeHost as d, SharedLink as f, webPort as i, GUIDE as l, openBrowser as n, webUrl as o, bridgePort as p, serveWeb as r, VERSION as s, TOOLS as t, BridgeClient as u };