accelr8 3.0.0 → 3.0.2

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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/protocol.ts
4
- var VERSION = "3.0.0";
4
+ var VERSION = true ? "3.0.2" : "3.0.2";
5
5
  var PROTO = 3;
6
6
  var PORT = 8787;
7
7
  var DEFAULT_SERVER = "wss://accelr8-canvas.patonchain.workers.dev";
@@ -36,7 +36,11 @@ var LIMITS = {
36
36
  agentQueueCap: 5,
37
37
  agentMaxDepth: 3,
38
38
  agentBudgetPerTrigger: 5,
39
- agentRepeatPerChain: 2
39
+ agentRepeatPerChain: 2,
40
+ voiceFramesPerSec: 50,
41
+ // ~25/s real, 2x headroom; exempt from framesPerWindow
42
+ voiceFrameMaxB64: 4096
43
+ // a 40ms s16le mono chunk is ~1.7KB base64; cap slack
40
44
  };
41
45
  var DEFAULT_ACCESS = { mode: "owner", users: [] };
42
46
  function encode(msg) {
@@ -7,7 +7,7 @@ import {
7
7
  VERSION,
8
8
  decode,
9
9
  encode
10
- } from "./chunk-SF6NNSYZ.js";
10
+ } from "./chunk-WRSSNAIF.js";
11
11
  import {
12
12
  DEFAULT_MODEL,
13
13
  addAccount,
@@ -25,7 +25,7 @@ import {
25
25
  // src/client.ts
26
26
  import tk from "terminal-kit";
27
27
  import { WebSocket } from "ws";
28
- import { spawn } from "node:child_process";
28
+ import { spawn as spawn2 } from "node:child_process";
29
29
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
30
30
  import { homedir } from "node:os";
31
31
  import { join } from "node:path";
@@ -1173,6 +1173,295 @@ function microLine(w, engine, frame2, info) {
1173
1173
  return clampTo(tiny, Math.max(0, w));
1174
1174
  }
1175
1175
 
1176
+ // src/voice.ts
1177
+ import { spawn, spawnSync } from "node:child_process";
1178
+ import fs from "node:fs";
1179
+ import path from "node:path";
1180
+ var SAMPLE_RATE = 16e3;
1181
+ var CHUNK_BYTES = 1280;
1182
+ var KILL_GRACE_MS = 300;
1183
+ var SPEAKER_IDLE_MS = 2e3;
1184
+ var DRAIN_KILL_MS = 2e3;
1185
+ var MAX_PLAYERS = 8;
1186
+ var STDERR_TAIL = 400;
1187
+ var VERIFY_MIN_BYTES = SAMPLE_RATE * 2 / 10;
1188
+ var PLAYBACK_ARGS = [
1189
+ "-hide_banner",
1190
+ "-loglevel",
1191
+ "error",
1192
+ "-nodisp",
1193
+ "-fflags",
1194
+ "nobuffer",
1195
+ "-flags",
1196
+ "low_delay",
1197
+ "-f",
1198
+ "s16le",
1199
+ "-ar",
1200
+ String(SAMPLE_RATE),
1201
+ "-ch_layout",
1202
+ "mono",
1203
+ "-i",
1204
+ "-"
1205
+ ];
1206
+ function detectVoiceTools() {
1207
+ const ffmpegPath = which("ffmpeg");
1208
+ const ffplayPath = which("ffplay");
1209
+ const out = { capture: !!ffmpegPath, playback: !!ffplayPath };
1210
+ if (ffmpegPath) out.ffmpegPath = ffmpegPath;
1211
+ if (ffplayPath) out.ffplayPath = ffplayPath;
1212
+ if (!ffmpegPath || !ffplayPath) {
1213
+ const missing = [ffmpegPath ? "" : "ffmpeg", ffplayPath ? "" : "ffplay"].filter(Boolean).join(" + ");
1214
+ out.reason = `${missing} not found on PATH (voice needs ffmpeg; brew/apt install ffmpeg)`;
1215
+ }
1216
+ return out;
1217
+ }
1218
+ function which(bin) {
1219
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
1220
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
1221
+ for (const ext of exts) {
1222
+ const candidate = path.join(dir, bin + ext);
1223
+ try {
1224
+ if (!fs.existsSync(candidate)) continue;
1225
+ fs.accessSync(candidate, fs.constants.X_OK);
1226
+ if (fs.statSync(candidate).isFile()) return candidate;
1227
+ } catch {
1228
+ }
1229
+ }
1230
+ }
1231
+ return void 0;
1232
+ }
1233
+ function captureArgs(platform, device) {
1234
+ const base = ["-hide_banner", "-loglevel", "error"];
1235
+ const output = ["-ac", "1", "-ar", String(SAMPLE_RATE), "-f", "s16le", "-"];
1236
+ if (platform === "darwin") return [...base, "-f", "avfoundation", "-i", ":" + (device ?? "default"), ...output];
1237
+ if (platform === "win32") return [...base, "-f", "dshow", "-i", "audio=" + (device ?? "default"), ...output];
1238
+ return [...base, "-f", "alsa", "-i", device ?? "default", ...output];
1239
+ }
1240
+ function chunkPcm(buf, chunkBytes) {
1241
+ if (!Number.isInteger(chunkBytes) || chunkBytes <= 0) throw new RangeError("chunkBytes must be a positive integer");
1242
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
1243
+ const out = [];
1244
+ for (let i = 0; i < b.length; i += chunkBytes) out.push(b.subarray(i, i + chunkBytes).toString("base64"));
1245
+ return out;
1246
+ }
1247
+ var VoiceCapture = class {
1248
+ constructor(tools) {
1249
+ this.tools = tools;
1250
+ }
1251
+ tools;
1252
+ session = null;
1253
+ get active() {
1254
+ return this.session !== null;
1255
+ }
1256
+ /** Spawn ffmpeg mic->PCM and stream ~40ms base64 chunks to onChunk.
1257
+ * No-op while a capture is already active. */
1258
+ start(opts) {
1259
+ if (this.session) return;
1260
+ if (!this.tools.capture || !this.tools.ffmpegPath) {
1261
+ const why = this.tools.reason ?? "ffmpeg not found on PATH";
1262
+ queueMicrotask(() => opts.onError(why));
1263
+ return;
1264
+ }
1265
+ this.session = this.spawnSession(opts, captureArgs(process.platform, opts.device), "primary");
1266
+ }
1267
+ /** Flush the sub-chunk remainder, then SIGINT -> SIGKILL after 300ms. */
1268
+ stop() {
1269
+ const s = this.session;
1270
+ if (!s) return;
1271
+ this.session = null;
1272
+ s.stopped = true;
1273
+ if (s.buf.length) {
1274
+ s.onChunk(s.buf.toString("base64"));
1275
+ s.buf = Buffer.alloc(0);
1276
+ }
1277
+ try {
1278
+ s.child.kill("SIGINT");
1279
+ } catch {
1280
+ }
1281
+ const t = setTimeout(() => {
1282
+ try {
1283
+ s.child.kill("SIGKILL");
1284
+ } catch {
1285
+ }
1286
+ }, KILL_GRACE_MS);
1287
+ t.unref();
1288
+ }
1289
+ spawnSession(opts, args, backend) {
1290
+ let child;
1291
+ try {
1292
+ child = spawn(this.tools.ffmpegPath, args, { stdio: ["ignore", "pipe", "pipe"] });
1293
+ } catch (e) {
1294
+ queueMicrotask(() => opts.onError("mic capture failed to start: " + errMsg(e)));
1295
+ return null;
1296
+ }
1297
+ const s = { child, onChunk: opts.onChunk, buf: Buffer.alloc(0), stderrTail: "", produced: false, stopped: false };
1298
+ child.stdout?.on("data", (chunk) => {
1299
+ if (s.stopped) return;
1300
+ s.produced = true;
1301
+ s.buf = s.buf.length ? Buffer.concat([s.buf, chunk]) : chunk;
1302
+ const whole = Math.floor(s.buf.length / CHUNK_BYTES) * CHUNK_BYTES;
1303
+ if (whole === 0) return;
1304
+ for (const b64 of chunkPcm(s.buf.subarray(0, whole), CHUNK_BYTES)) opts.onChunk(b64);
1305
+ s.buf = s.buf.subarray(whole);
1306
+ });
1307
+ child.stderr?.setEncoding("utf8");
1308
+ child.stderr?.on("data", (c) => {
1309
+ s.stderrTail = (s.stderrTail + c).slice(-STDERR_TAIL);
1310
+ });
1311
+ child.on("error", (e) => {
1312
+ if (s.stopped || this.session !== s) return;
1313
+ this.session = null;
1314
+ opts.onError(e.code === "ENOENT" ? "ffmpeg not found" : "mic capture error: " + errMsg(e));
1315
+ });
1316
+ child.on("close", (code) => {
1317
+ if (s.stopped || this.session !== s) return;
1318
+ if (backend === "primary" && process.platform === "linux" && !s.produced && code !== 0) {
1319
+ const pulse = captureArgs("linux", opts.device).map((a) => a === "alsa" ? "pulse" : a);
1320
+ this.session = this.spawnSession(opts, pulse, "pulse");
1321
+ return;
1322
+ }
1323
+ this.session = null;
1324
+ const tail = s.stderrTail.trim();
1325
+ opts.onError(code === 0 ? "mic capture ended unexpectedly" : "mic capture failed: " + (tail || `ffmpeg exited with code ${code}`));
1326
+ });
1327
+ return s;
1328
+ }
1329
+ };
1330
+ var VoicePlayback = class {
1331
+ constructor(tools) {
1332
+ this.tools = tools;
1333
+ }
1334
+ tools;
1335
+ players = /* @__PURE__ */ new Map();
1336
+ /** ids with an active player */
1337
+ get speakers() {
1338
+ return [...this.players.keys()];
1339
+ }
1340
+ /** Feed a received chunk; lazily spawns one ffplay per speaker. Chunks
1341
+ * are dropped silently when playback is unavailable, the speaker cap
1342
+ * is hit, or a player cannot be (re)spawned. */
1343
+ feed(speakerId, b64) {
1344
+ if (!this.tools.playback || !this.tools.ffplayPath) return;
1345
+ const bytes = Buffer.from(b64, "base64");
1346
+ if (bytes.length === 0) return;
1347
+ let p = this.players.get(speakerId);
1348
+ if (p && !p.alive) {
1349
+ this.reap(speakerId, p);
1350
+ p = void 0;
1351
+ }
1352
+ if (!p) {
1353
+ if (this.players.size >= MAX_PLAYERS) return;
1354
+ const spawned = this.spawnPlayer(speakerId);
1355
+ if (!spawned) return;
1356
+ p = spawned;
1357
+ }
1358
+ if (!this.write(p, bytes)) {
1359
+ this.reap(speakerId, p);
1360
+ const fresh = this.players.size < MAX_PLAYERS ? this.spawnPlayer(speakerId) : null;
1361
+ if (!fresh || !this.write(fresh, bytes)) return;
1362
+ p = fresh;
1363
+ }
1364
+ this.touch(speakerId, p);
1365
+ }
1366
+ /** Graceful close for one speaker (voiceStop): end stdin so ffplay drains
1367
+ * its buffered audio, then a SIGKILL backstop after 2s (we dropped -autoexit,
1368
+ * so the backstop is what actually reaps the idle player). */
1369
+ end(speakerId) {
1370
+ const p = this.players.get(speakerId);
1371
+ if (!p) return;
1372
+ this.players.delete(speakerId);
1373
+ if (p.idleTimer) {
1374
+ clearTimeout(p.idleTimer);
1375
+ p.idleTimer = null;
1376
+ }
1377
+ try {
1378
+ p.child.stdin?.end();
1379
+ } catch {
1380
+ }
1381
+ const t = setTimeout(() => {
1382
+ try {
1383
+ p.child.kill("SIGKILL");
1384
+ } catch {
1385
+ }
1386
+ }, DRAIN_KILL_MS);
1387
+ t.unref();
1388
+ }
1389
+ /** Hard cleanup on quit: kill every player immediately. */
1390
+ stopAll() {
1391
+ for (const [, p] of this.players) {
1392
+ if (p.idleTimer) {
1393
+ clearTimeout(p.idleTimer);
1394
+ p.idleTimer = null;
1395
+ }
1396
+ p.alive = false;
1397
+ try {
1398
+ p.child.kill("SIGKILL");
1399
+ } catch {
1400
+ }
1401
+ }
1402
+ this.players.clear();
1403
+ }
1404
+ spawnPlayer(speakerId) {
1405
+ let child;
1406
+ try {
1407
+ child = spawn(this.tools.ffplayPath, PLAYBACK_ARGS, { stdio: ["pipe", "ignore", "ignore"] });
1408
+ } catch {
1409
+ return null;
1410
+ }
1411
+ const p = { child, alive: true, idleTimer: null };
1412
+ child.stdin?.on("error", () => {
1413
+ p.alive = false;
1414
+ });
1415
+ child.on("error", () => {
1416
+ p.alive = false;
1417
+ if (this.players.get(speakerId) === p) this.reap(speakerId, p);
1418
+ });
1419
+ child.on("close", () => {
1420
+ p.alive = false;
1421
+ if (this.players.get(speakerId) === p) {
1422
+ if (p.idleTimer) {
1423
+ clearTimeout(p.idleTimer);
1424
+ p.idleTimer = null;
1425
+ }
1426
+ this.players.delete(speakerId);
1427
+ }
1428
+ });
1429
+ this.players.set(speakerId, p);
1430
+ return p;
1431
+ }
1432
+ write(p, bytes) {
1433
+ const sin = p.child.stdin;
1434
+ if (!p.alive || !sin || sin.destroyed || !sin.writable) return false;
1435
+ try {
1436
+ sin.write(bytes);
1437
+ return true;
1438
+ } catch {
1439
+ p.alive = false;
1440
+ return false;
1441
+ }
1442
+ }
1443
+ touch(speakerId, p) {
1444
+ if (p.idleTimer) clearTimeout(p.idleTimer);
1445
+ p.idleTimer = setTimeout(() => this.end(speakerId), SPEAKER_IDLE_MS);
1446
+ p.idleTimer.unref();
1447
+ }
1448
+ reap(speakerId, p) {
1449
+ if (p.idleTimer) {
1450
+ clearTimeout(p.idleTimer);
1451
+ p.idleTimer = null;
1452
+ }
1453
+ p.alive = false;
1454
+ try {
1455
+ p.child.kill("SIGKILL");
1456
+ } catch {
1457
+ }
1458
+ if (this.players.get(speakerId) === p) this.players.delete(speakerId);
1459
+ }
1460
+ };
1461
+ function errMsg(e) {
1462
+ return e instanceof Error ? e.message : String(e);
1463
+ }
1464
+
1176
1465
  // src/client.ts
1177
1466
  var term = tk.terminal;
1178
1467
  var ScreenBuffer = tk.ScreenBuffer;
@@ -1223,26 +1512,135 @@ var overlay = null;
1223
1512
  var accessAgentId = null;
1224
1513
  var overlayFocus = 0;
1225
1514
  var updateInfo = null;
1226
- var updating = null;
1227
- var cancelUpdate = null;
1515
+ var task = null;
1516
+ var cancelTask = null;
1228
1517
  function startUpdate() {
1229
- if (updating && !updating.done) return;
1230
- updating = { line: "starting npm\u2026", message: "" };
1518
+ if (task && task.phase === "running") return;
1519
+ task = { title: "updating accelr8", phase: "running", line: "starting npm\u2026", okLabel: "quit now \xB7 enter", onOk: () => shutdown() };
1231
1520
  import("./update-UEPNGB6Y.js").then(({ runUpdate }) => {
1232
- cancelUpdate = runUpdate({
1521
+ cancelTask = runUpdate({
1233
1522
  onLine: (l) => {
1234
- if (updating) updating.line = l.slice(0, 120);
1523
+ if (task) task.line = l.slice(0, 120);
1235
1524
  },
1236
1525
  onDone: (ok, message) => {
1237
- updating = { line: "", done: ok ? "ok" : "fail", message };
1238
- cancelUpdate = null;
1526
+ if (task) {
1527
+ task.phase = "done";
1528
+ task.ok = ok;
1529
+ task.line = message;
1530
+ }
1531
+ cancelTask = null;
1239
1532
  if (ok) updateInfo = null;
1240
1533
  }
1241
1534
  });
1242
1535
  }).catch(() => {
1243
- updating = { line: "", done: "fail", message: "could not load the updater \xB7 npm i -g accelr8@latest" };
1536
+ if (task) {
1537
+ task.phase = "done";
1538
+ task.ok = false;
1539
+ task.line = "could not load the updater \xB7 npm i -g accelr8@latest";
1540
+ }
1541
+ });
1542
+ }
1543
+ function startVoiceInstall() {
1544
+ import("./install-DL5WLVXN.js").then(({ ffmpegInstallPlan, runCommand }) => {
1545
+ const plan = ffmpegInstallPlan();
1546
+ if (!plan.auto) {
1547
+ state.status = `voice needs ffmpeg \xB7 ${plan.manual}`;
1548
+ return;
1549
+ }
1550
+ const auto = plan.auto;
1551
+ task = {
1552
+ title: "enable voice",
1553
+ phase: "confirm",
1554
+ line: `voice needs ffmpeg \xB7 ${auto.label} \xB7 press enter`,
1555
+ run: () => {
1556
+ if (!task) return;
1557
+ task.phase = "running";
1558
+ task.line = "installing ffmpeg\u2026";
1559
+ cancelTask = runCommand(auto.cmd, auto.args, {
1560
+ onLine: (l) => {
1561
+ if (task) task.line = l.slice(0, 120);
1562
+ },
1563
+ onDone: (ok, message) => {
1564
+ if (task) {
1565
+ task.phase = "done";
1566
+ task.ok = ok;
1567
+ task.line = ok ? "ffmpeg ready \xB7 voice is on" : message + " \xB7 " + auto.label;
1568
+ task.okLabel = ok ? "start voice \xB7 enter" : void 0;
1569
+ }
1570
+ cancelTask = null;
1571
+ }
1572
+ });
1573
+ },
1574
+ onOk: () => {
1575
+ voiceTools = null;
1576
+ task = null;
1577
+ toggleVoice();
1578
+ }
1579
+ // re-detect + join
1580
+ };
1581
+ }).catch(() => {
1582
+ state.status = "could not load the installer";
1244
1583
  });
1245
1584
  }
1585
+ var voiceTools = null;
1586
+ var voiceOn = false;
1587
+ var voiceTransmit = false;
1588
+ var voiceCapture = null;
1589
+ var voicePlayback = null;
1590
+ function toggleVoice() {
1591
+ if (voiceOn) return leaveVoice();
1592
+ if (!state.project) {
1593
+ state.status = "join a project first";
1594
+ return;
1595
+ }
1596
+ voiceTools = voiceTools ?? detectVoiceTools();
1597
+ if (!voiceTools.capture && !voiceTools.playback) {
1598
+ startVoiceInstall();
1599
+ return;
1600
+ }
1601
+ voicePlayback = new VoicePlayback(voiceTools);
1602
+ voiceCapture = new VoiceCapture(voiceTools);
1603
+ voiceOn = true;
1604
+ voiceTransmit = false;
1605
+ sendMsg({ t: "voiceJoin" });
1606
+ state.status = voiceTools.capture ? "voice on \xB7 /talk (or ctrl-g) to speak" : "voice on \xB7 listen only (no mic found)";
1607
+ }
1608
+ function leaveVoice() {
1609
+ if (!voiceOn) return;
1610
+ voiceCapture?.stop();
1611
+ voicePlayback?.stopAll();
1612
+ voiceCapture = null;
1613
+ voicePlayback = null;
1614
+ voiceOn = false;
1615
+ voiceTransmit = false;
1616
+ state.voicePeers = [];
1617
+ sendMsg({ t: "voiceLeave" });
1618
+ state.status = "voice off";
1619
+ }
1620
+ function toggleTalk() {
1621
+ if (!voiceOn || !voiceCapture) return;
1622
+ if (!voiceTools?.capture) {
1623
+ state.status = "no mic \xB7 install ffmpeg to talk";
1624
+ return;
1625
+ }
1626
+ if (voiceTransmit) {
1627
+ voiceCapture.stop();
1628
+ voiceTransmit = false;
1629
+ state.status = "muted \xB7 /talk (or ctrl-g) to speak";
1630
+ } else {
1631
+ voiceTransmit = true;
1632
+ state.status = "\u25CF live \xB7 /mute (or ctrl-g) to stop";
1633
+ voiceCapture.start({
1634
+ onChunk: (b64) => {
1635
+ if (voiceTransmit) sendMsg({ t: "voiceFrame", data: b64 });
1636
+ },
1637
+ onError: (m) => {
1638
+ voiceTransmit = false;
1639
+ state.status = "mic error \xB7 " + m.slice(0, 44);
1640
+ }
1641
+ });
1642
+ }
1643
+ }
1246
1644
  function openHelp() {
1247
1645
  overlay = "help";
1248
1646
  overlayFocus = 0;
@@ -1253,6 +1651,7 @@ function openSettings() {
1253
1651
  }
1254
1652
  function goHome() {
1255
1653
  if (state.project) {
1654
+ if (voiceOn) leaveVoice();
1256
1655
  overlay = null;
1257
1656
  state.screen = "home";
1258
1657
  sendMsg({ t: "tab", tab: "home" });
@@ -1270,6 +1669,7 @@ var state = {
1270
1669
  sessions: [],
1271
1670
  agents: [],
1272
1671
  secrets: [],
1672
+ voicePeers: [],
1273
1673
  status: "",
1274
1674
  modal: null
1275
1675
  };
@@ -1664,6 +2064,12 @@ function handleServer(msg) {
1664
2064
  case "secrets":
1665
2065
  state.secrets = Array.isArray(msg.list) ? msg.list : [];
1666
2066
  break;
2067
+ case "voice":
2068
+ state.voicePeers = Array.isArray(msg.peers) ? msg.peers : [];
2069
+ break;
2070
+ case "voiceFrame":
2071
+ if (voiceOn) voicePlayback?.feed(msg.sid, msg.data);
2072
+ break;
1667
2073
  case "error": {
1668
2074
  if (msg.code === "chatRate" || msg.code === "msgTooLarge") {
1669
2075
  const failed = msg.ref ? chatLog.failOptimistic(msg.ref) : null;
@@ -1851,6 +2257,9 @@ function __curTheme() {
1851
2257
  function __setUpdate(latest, current = "3.0.0") {
1852
2258
  updateInfo = latest ? { latest, current } : null;
1853
2259
  }
2260
+ function __taskDemo(phase, ok = true) {
2261
+ task = { title: "enable voice", phase, ok, line: phase === "confirm" ? "voice needs ffmpeg \xB7 brew install ffmpeg \xB7 press enter" : phase === "running" ? "installing ffmpeg\u2026" : ok ? "ffmpeg ready \xB7 voice is on" : "brew exited 1 \xB7 brew install ffmpeg", okLabel: "start voice \xB7 enter" };
2262
+ }
1854
2263
  function __closeOverlay() {
1855
2264
  overlay = null;
1856
2265
  }
@@ -1936,28 +2345,36 @@ function render() {
1936
2345
  overlayFocus = clickables.length ? Math.max(0, Math.min(overlayFocus, clickables.length - 1)) : 0;
1937
2346
  }
1938
2347
  if (state.modal && bigEnough) renderModal();
1939
- if (updating && bigEnough) renderUpdating();
2348
+ if (task && bigEnough) renderTask();
1940
2349
  if (screen.dst) screen.draw({ delta: true });
1941
2350
  }
1942
- function renderUpdating() {
2351
+ function renderTask() {
1943
2352
  clickables = [];
1944
2353
  const w = W(), h = H();
1945
2354
  const bw = Math.min(66, w - 6), bh = 7;
1946
2355
  const x0 = Math.floor((w - bw) / 2), y0 = Math.floor((h - bh) / 2);
1947
2356
  for (let yy = y0; yy < y0 + bh; yy++) puts(x0, yy, " ".repeat(bw), { bgColor: C.panel });
1948
- box(x0, y0, x0 + bw - 1, y0 + bh - 1, { color: C.accent, bgColor: C.panel }, "updating accelr8");
1949
- const u = updating;
1950
- if (!u.done) {
1951
- puts(x0 + 2, y0 + 2, `${SPINNER[frame % SPINNER.length]} installing accelr8@latest\u2026`, { color: C.bright, bgColor: C.panel, bold: true });
1952
- puts(x0 + 2, y0 + 4, ("\xB7 " + u.line).slice(0, bw - 4), { color: C.dim, bgColor: C.panel });
1953
- } else if (u.done === "ok") {
1954
- puts(x0 + 2, y0 + 2, "\u2713 " + u.message, { color: C.good, bgColor: C.panel, bold: true });
1955
- button(x0 + 2, y0 + 4, " quit now \xB7 enter ", { bgColor: C.good, color: onFill(C.good), bold: true }, () => shutdown());
1956
- puts(x0 + 22, y0 + 4, "then run: accelr8", { color: C.dim, bgColor: C.panel });
2357
+ box(x0, y0, x0 + bw - 1, y0 + bh - 1, { color: C.accent, bgColor: C.panel }, task.title);
2358
+ const t = task;
2359
+ if (t.phase === "confirm") {
2360
+ puts(x0 + 2, y0 + 2, t.line.slice(0, bw - 4), { color: C.bright, bgColor: C.panel, bold: true });
2361
+ button(x0 + 2, y0 + 4, " install \xB7 enter ", { bgColor: C.accent, color: onFill(C.accent), bold: true }, () => t.run?.());
2362
+ button(x0 + 20, y0 + 4, " not now \xB7 esc ", { bgColor: C.tabOff, color: C.text }, () => {
2363
+ task = null;
2364
+ });
2365
+ } else if (t.phase === "running") {
2366
+ puts(x0 + 2, y0 + 2, `${SPINNER[frame % SPINNER.length]} ${t.title}\u2026`, { color: C.bright, bgColor: C.panel, bold: true });
2367
+ puts(x0 + 2, y0 + 4, ("\xB7 " + t.line).slice(0, bw - 4), { color: C.dim, bgColor: C.panel });
2368
+ } else if (t.ok) {
2369
+ puts(x0 + 2, y0 + 2, "\u2713 " + t.line.slice(0, bw - 6), { color: C.good, bgColor: C.panel, bold: true });
2370
+ if (t.okLabel) button(x0 + 2, y0 + 4, ` ${t.okLabel} `, { bgColor: C.good, color: onFill(C.good), bold: true }, () => t.onOk?.());
2371
+ else button(x0 + 2, y0 + 4, " close \xB7 enter ", { bgColor: C.good, color: onFill(C.good), bold: true }, () => {
2372
+ task = null;
2373
+ });
1957
2374
  } else {
1958
- puts(x0 + 2, y0 + 2, "\u2715 " + u.message.slice(0, bw - 6), { color: C.danger, bgColor: C.panel, bold: true });
2375
+ puts(x0 + 2, y0 + 2, "\u2715 " + t.line.slice(0, bw - 6), { color: C.danger, bgColor: C.panel, bold: true });
1959
2376
  button(x0 + 2, y0 + 4, " close \xB7 esc ", { bgColor: C.tabOff, color: C.text }, () => {
1960
- updating = null;
2377
+ task = null;
1961
2378
  });
1962
2379
  }
1963
2380
  }
@@ -2239,7 +2656,7 @@ function renderHome(rung) {
2239
2656
  }
2240
2657
  y += 1;
2241
2658
  }
2242
- if (updateInfo && !updating) {
2659
+ if (updateInfo && !task) {
2243
2660
  puts(2, y, "\u21BB", { color: C.warn, bold: true });
2244
2661
  const w2 = W();
2245
2662
  const label = ` update to v${updateInfo.latest} \xB7 press enter `;
@@ -2297,7 +2714,12 @@ function renderStatusBar(rung) {
2297
2714
  puts(0, sy, " ".repeat(w), { bgColor: C.panel });
2298
2715
  const agentBit = state.agents.length ? ` \xB7 \u2726${state.agents.length} agents` : "";
2299
2716
  const left = state.project ? ` ${state.project.name} \xB7 ${state.project.code} \xB7 ${onlineUserIds().size} online${agentBit} ` : " no project open ";
2300
- puts(0, sy, left.slice(0, w - 24), { bgColor: C.panel, color: C.dim });
2717
+ const drawn = left.slice(0, w - 24);
2718
+ puts(0, sy, drawn, { bgColor: C.panel, color: C.dim });
2719
+ if (voiceOn) {
2720
+ const label = voiceTransmit ? " \u25CF live \xB7 /mute" : ` \u25CB voice ${state.voicePeers.length} \xB7 /talk`;
2721
+ if (drawn.length + label.length < w - 24) puts(drawn.length, sy, label, { bgColor: C.panel, color: voiceTransmit ? C.good : C.accent, bold: voiceTransmit });
2722
+ }
2301
2723
  const hint = state.modal ? "[enter] ok \xB7 [esc] cancel " : "[tab] tabs \xB7 [^C] quit ";
2302
2724
  puts(w - hint.length, sy, hint, { bgColor: C.panel, color: C.dim });
2303
2725
  if (state.status) {
@@ -2954,7 +3376,7 @@ function copyToClipboard(text, tools) {
2954
3376
  return;
2955
3377
  }
2956
3378
  try {
2957
- const p = spawn(tool, args, { stdio: ["pipe", "ignore", "ignore"] });
3379
+ const p = spawn2(tool, args, { stdio: ["pipe", "ignore", "ignore"] });
2958
3380
  p.on("error", () => copyToClipboard(text, candidatesList.slice(1)));
2959
3381
  p.on("close", (code) => {
2960
3382
  state.status = code === 0 ? "copied join command" : "copy failed";
@@ -2976,7 +3398,7 @@ function onMouse(name, data) {
2976
3398
  return;
2977
3399
  }
2978
3400
  if (state.modal) return;
2979
- if (updating) {
3401
+ if (task) {
2980
3402
  if (name === "MOUSE_LEFT_BUTTON_PRESSED") {
2981
3403
  for (const r of clickables) if (mx >= r.x0 && mx <= r.x1 && my >= r.y0 && my <= r.y1) {
2982
3404
  r.action();
@@ -3092,6 +3514,10 @@ function slashCommand(text) {
3092
3514
  ghostsOff = cmd.endsWith("off");
3093
3515
  state.status = ghostsOff ? "draft broadcasting off" : "draft broadcasting on";
3094
3516
  draftDirty = true;
3517
+ } else if (cmd === "/voice") {
3518
+ toggleVoice();
3519
+ } else if (cmd === "/talk" || cmd === "/mute") {
3520
+ toggleTalk();
3095
3521
  } else {
3096
3522
  state.status = `unknown command: ${cmd.split(" ")[0]}`;
3097
3523
  }
@@ -3118,24 +3544,27 @@ function sendChat() {
3118
3544
  }
3119
3545
  function onKey(name) {
3120
3546
  if (name === "CTRL_C") return shutdown();
3547
+ if ((name === "CTRL_G" || name === "CTRL_ALT_G") && voiceOn) return toggleTalk();
3121
3548
  if (boot) return skipBoot();
3122
3549
  const rung = rungFor(W(), H());
3123
3550
  if (rung === "micro" || rung === "orb") return;
3124
3551
  const paused = rung === "ticker";
3125
- if (updating && !paused) {
3126
- if (!updating.done) {
3127
- if (name === "ESCAPE") {
3128
- cancelUpdate?.();
3129
- updating = null;
3130
- }
3552
+ if (task && !paused) {
3553
+ const enter = name === "ENTER" || name === "KP_ENTER";
3554
+ if (task.phase === "confirm") {
3555
+ if (enter) task.run?.();
3556
+ else if (name === "ESCAPE") task = null;
3131
3557
  return;
3132
3558
  }
3133
- if (updating.done === "ok") {
3134
- if (name === "ENTER" || name === "KP_ENTER" || name === "q") return shutdown();
3135
- if (name === "ESCAPE") updating = null;
3559
+ if (task.phase === "running") {
3560
+ if (name === "ESCAPE") {
3561
+ cancelTask?.();
3562
+ task = null;
3563
+ }
3136
3564
  return;
3137
3565
  }
3138
- if (name === "ESCAPE" || name === "ENTER" || name === "KP_ENTER") updating = null;
3566
+ if (enter && task.ok && task.onOk) return task.onOk();
3567
+ if (enter || name === "ESCAPE") task = null;
3139
3568
  return;
3140
3569
  }
3141
3570
  if (state.modal && !paused) {
@@ -3358,6 +3787,11 @@ function restoreTerminal() {
3358
3787
  }
3359
3788
  function shutdown() {
3360
3789
  restoreTerminal();
3790
+ try {
3791
+ voiceCapture?.stop();
3792
+ voicePlayback?.stopAll();
3793
+ } catch {
3794
+ }
3361
3795
  for (const h of runningExec.values()) {
3362
3796
  try {
3363
3797
  h.abort();
@@ -3440,6 +3874,7 @@ export {
3440
3874
  __setMySid,
3441
3875
  __setNav,
3442
3876
  __setUpdate,
3877
+ __taskDemo,
3443
3878
  chatLog,
3444
3879
  cursors,
3445
3880
  editor,
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/install.ts
4
+ import { spawn } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ function which(bin) {
8
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
9
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
10
+ for (const ext of exts) {
11
+ const p = path.join(dir, bin + ext);
12
+ try {
13
+ fs.accessSync(p, fs.constants.X_OK);
14
+ if (fs.statSync(p).isFile()) return true;
15
+ } catch {
16
+ }
17
+ }
18
+ }
19
+ return false;
20
+ }
21
+ function ffmpegInstallPlan(platform = process.platform) {
22
+ if (platform === "darwin") {
23
+ if (which("brew")) return { auto: { cmd: "brew", args: ["install", "ffmpeg"], label: "brew install ffmpeg" }, manual: "brew install ffmpeg" };
24
+ return { manual: "install Homebrew from brew.sh, then: brew install ffmpeg", note: "Homebrew not found" };
25
+ }
26
+ if (platform === "win32") {
27
+ if (which("winget")) return { auto: { cmd: "winget", args: ["install", "--silent", "--accept-package-agreements", "--accept-source-agreements", "Gyan.FFmpeg"], label: "winget install ffmpeg" }, manual: "winget install Gyan.FFmpeg" };
28
+ if (which("choco")) return { auto: { cmd: "choco", args: ["install", "-y", "ffmpeg-full"], label: "choco install ffmpeg-full" }, manual: "choco install ffmpeg-full" };
29
+ return { manual: "install ffmpeg from ffmpeg.org and add it to PATH", note: "no winget/choco found" };
30
+ }
31
+ if (which("apt-get") || which("apt")) return { manual: "sudo apt install ffmpeg", note: "needs sudo \u2014 run it in your shell" };
32
+ if (which("dnf")) return { manual: "sudo dnf install ffmpeg", note: "needs sudo \u2014 run it in your shell" };
33
+ if (which("pacman")) return { manual: "sudo pacman -S ffmpeg", note: "needs sudo \u2014 run it in your shell" };
34
+ return { manual: "install ffmpeg with your package manager", note: "unknown package manager" };
35
+ }
36
+ function runCommand(cmd, args, cb) {
37
+ let child;
38
+ try {
39
+ child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
40
+ } catch (e) {
41
+ queueMicrotask(() => cb.onDone(false, `couldn't start ${cmd}`));
42
+ return () => {
43
+ };
44
+ }
45
+ let tail = "";
46
+ const feed = (chunk) => {
47
+ tail += chunk.toString();
48
+ let nl;
49
+ while ((nl = tail.indexOf("\n")) >= 0) {
50
+ const line = tail.slice(0, nl).trim();
51
+ tail = tail.slice(nl + 1);
52
+ if (line) cb.onLine(line);
53
+ }
54
+ };
55
+ child.stdout?.on("data", feed);
56
+ child.stderr?.on("data", feed);
57
+ child.on("error", (e) => cb.onDone(false, e.code === "ENOENT" ? `${cmd} not found` : e.message));
58
+ child.on("close", (code) => {
59
+ if (tail.trim()) cb.onLine(tail.trim());
60
+ cb.onDone(code === 0, code === 0 ? "installed" : `${cmd} exited ${code}`);
61
+ });
62
+ return () => {
63
+ try {
64
+ child.kill();
65
+ } catch {
66
+ }
67
+ };
68
+ }
69
+ export {
70
+ ffmpegInstallPlan,
71
+ runCommand
72
+ };
@@ -6,7 +6,7 @@ import {
6
6
  LIMITS,
7
7
  PORT,
8
8
  PROTO
9
- } from "./chunk-SF6NNSYZ.js";
9
+ } from "./chunk-WRSSNAIF.js";
10
10
 
11
11
  // src/server.ts
12
12
  import { WebSocketServer, WebSocket } from "ws";
@@ -109,7 +109,6 @@ function sanitizeText(v, maxCp) {
109
109
  return out;
110
110
  }
111
111
  var RoomCore = class {
112
- // userId -> trigger times
113
112
  constructor(host) {
114
113
  this.host = host;
115
114
  }
@@ -135,6 +134,11 @@ var RoomCore = class {
135
134
  invokeWindow = /* @__PURE__ */ new Map();
136
135
  // projectId -> invocation times
137
136
  triggerWindow = /* @__PURE__ */ new Map();
137
+ // userId -> trigger times
138
+ // voice channel (realtime, loss-tolerant): sids currently in voice, and a
139
+ // per-sid rate window (voiceFrame is exempt from the general frame flood cap)
140
+ voiceSids = /* @__PURE__ */ new Set();
141
+ voiceWindow = /* @__PURE__ */ new Map();
138
142
  // ---------- lifecycle ----------
139
143
  async onMessage(conn, raw) {
140
144
  let msg;
@@ -145,14 +149,16 @@ var RoomCore = class {
145
149
  }
146
150
  if (!msg || typeof msg !== "object" || typeof msg.t !== "string") return;
147
151
  const now = this.host.now();
148
- const win = (this.frameWindow.get(conn) ?? []).filter((t) => now - t < 1e4);
149
- if (win.length >= LIMITS.framesPerWindow) {
152
+ if (msg.t !== "voiceFrame") {
153
+ const win = (this.frameWindow.get(conn) ?? []).filter((t) => now - t < 1e4);
154
+ if (win.length >= LIMITS.framesPerWindow) {
155
+ this.frameWindow.set(conn, win);
156
+ this.host.close(conn, 1008, "frame flood");
157
+ return;
158
+ }
159
+ win.push(now);
150
160
  this.frameWindow.set(conn, win);
151
- this.host.close(conn, 1008, "frame flood");
152
- return;
153
161
  }
154
- win.push(now);
155
- this.frameWindow.set(conn, win);
156
162
  try {
157
163
  await this.dispatch(conn, msg);
158
164
  } catch (e) {
@@ -322,6 +328,34 @@ var RoomCore = class {
322
328
  for (const c of this.roomConns(s.projectId)) this.host.send(c, { t: "cleared" });
323
329
  break;
324
330
  }
331
+ case "voiceJoin": {
332
+ if (!s.projectId || this.voiceSids.has(s.sid)) break;
333
+ this.voiceSids.add(s.sid);
334
+ this.broadcastVoice(s.projectId);
335
+ await this.systemLine(s.projectId, `${s.name} joined voice`);
336
+ break;
337
+ }
338
+ case "voiceLeave": {
339
+ if (!s.projectId || !this.voiceSids.has(s.sid)) break;
340
+ this.voiceSids.delete(s.sid);
341
+ this.voiceWindow.delete(s.sid);
342
+ this.broadcastVoice(s.projectId);
343
+ await this.systemLine(s.projectId, `${s.name} left voice`);
344
+ break;
345
+ }
346
+ case "voiceFrame": {
347
+ if (!s.projectId || !this.voiceSids.has(s.sid)) break;
348
+ const data = typeof msg.data === "string" ? msg.data : "";
349
+ if (!data || data.length > LIMITS.voiceFrameMaxB64) break;
350
+ if (!this.rateOk(this.voiceWindow, s.sid, LIMITS.voiceFramesPerSec, 1e3)) break;
351
+ for (const c of this.roomConns(s.projectId)) {
352
+ const peer = this.host.getSession(c);
353
+ if (peer && peer.sid !== s.sid && this.voiceSids.has(peer.sid)) {
354
+ this.host.send(c, { t: "voiceFrame", sid: s.sid, data });
355
+ }
356
+ }
357
+ break;
358
+ }
325
359
  case "chatSend": {
326
360
  if (!s.projectId) break;
327
361
  const cmid = String(msg.cmid || "").slice(0, 40);
@@ -519,12 +553,18 @@ var RoomCore = class {
519
553
  if (inv && inv.projectId === pid) void this.finalizeInvocation(inv, "partial");
520
554
  this.queues.delete(agentId);
521
555
  }
556
+ const wasInVoice = this.voiceSids.delete(s.sid);
557
+ this.voiceWindow.delete(s.sid);
522
558
  s.projectId = null;
523
559
  this.host.setSession(conn, s);
524
560
  for (const c of this.roomPeers(pid, conn)) {
525
561
  this.host.send(c, { t: "cursorGone", sid: s.sid });
526
562
  }
527
563
  this.broadcastSessions(pid);
564
+ if (wasInVoice) {
565
+ this.broadcastVoice(pid);
566
+ void this.systemLine(pid, `${s.name} left voice`);
567
+ }
528
568
  if (agentsChanged) {
529
569
  const p = this.projectsSync?.get(pid);
530
570
  if (p) this.broadcastAgents(pid);
@@ -757,6 +797,11 @@ var RoomCore = class {
757
797
  const sessions = this.sessionsFor(projectId);
758
798
  for (const c of this.roomConns(projectId)) this.host.send(c, { t: "sessions", sessions });
759
799
  }
800
+ /** who is in voice for this project (join/leave presence, not per-frame). */
801
+ broadcastVoice(projectId) {
802
+ const peers = this.roomConns(projectId).map((c) => this.host.getSession(c)).filter((s) => !!s && this.voiceSids.has(s.sid)).map((s) => ({ sid: s.sid, userId: s.userId, name: s.name, color: s.color }));
803
+ for (const c of this.roomConns(projectId)) this.host.send(c, { t: "voice", peers });
804
+ }
760
805
  onlineIds(projectId) {
761
806
  return [...new Set(this.sessionsFor(projectId).map((s) => s.userId))];
762
807
  }
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  DEFAULT_SERVER,
4
4
  PORT,
5
5
  VERSION
6
- } from "./chunks/chunk-SF6NNSYZ.js";
6
+ } from "./chunks/chunk-WRSSNAIF.js";
7
7
 
8
8
  // src/cli.ts
9
9
  var HELP = `
@@ -52,7 +52,7 @@ async function main() {
52
52
  console.error(`invalid port: ${argVal(args, "--port")}`);
53
53
  process.exit(1);
54
54
  }
55
- const { runServer } = await import("./chunks/server-ZP2DV4IO.js");
55
+ const { runServer } = await import("./chunks/server-OQNHWL3Z.js");
56
56
  runServer(port);
57
57
  return;
58
58
  }
@@ -76,7 +76,7 @@ async function main() {
76
76
  const { cachedUpdate, refreshUpdateCache } = await import("./chunks/update-UEPNGB6Y.js");
77
77
  refreshUpdateCache();
78
78
  const update = cachedUpdate(VERSION);
79
- const { runClient } = await import("./chunks/client-7BVK2DEW.js");
79
+ const { runClient } = await import("./chunks/client-W4563KDL.js");
80
80
  runClient({
81
81
  name: argVal(args, "--name"),
82
82
  server: argVal(args, "--server"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "accelr8",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "Collaborative ascii canvas in your terminal. Real multiplayer, live cursors, mouse-driven TUI.",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -42,7 +42,7 @@
42
42
  "chattest": "tsx src/chattest.ts",
43
43
  "inputtest": "tsx src/inputtest.ts",
44
44
  "torturetest": "tsx src/torturetest.ts",
45
- "test": "for t in core chat input ladder boot orb crypto accounts executor agentcontext map voice glyph materialize update contrast; do tsx src/${t}test.ts || exit 1; done && tsx src/rendertest.ts > /dev/null && echo RENDER OK",
45
+ "test": "for t in core chat input ladder boot orb crypto accounts executor agentcontext map voice glyph materialize update install contrast; do tsx src/${t}test.ts || exit 1; done && tsx src/rendertest.ts > /dev/null && echo RENDER OK",
46
46
  "laddertest": "tsx src/laddertest.ts",
47
47
  "boottest": "tsx src/boottest.ts",
48
48
  "reconnecttest": "tsx src/reconnecttest.ts",
@@ -53,7 +53,8 @@
53
53
  "accountstest": "tsx src/accountstest.ts",
54
54
  "executortest": "tsx src/executortest.ts",
55
55
  "agentcontexttest": "tsx src/agentcontexttest.ts",
56
- "chaintest": "tsx src/chaintest.ts"
56
+ "chaintest": "tsx src/chaintest.ts",
57
+ "voicetransporttest": "tsx src/voicetransporttest.ts"
57
58
  },
58
59
  "dependencies": {
59
60
  "terminal-kit": "3.1.2",