vibestreams 0.1.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 ADDED
@@ -0,0 +1,23 @@
1
+ # Vibestreams CLI
2
+
3
+ Broadcast a read-only coding terminal to Vibestreams. Terminal output and
4
+ supported agent prompts are masked for likely secrets on your machine before
5
+ they are sent.
6
+
7
+ ```sh
8
+ npx vibestreams
9
+ npx vibestreams --unlisted --no-prompts "private refactor"
10
+ ```
11
+
12
+ The package installs both `vibestreams` and `vibestream` as command names. On
13
+ first run it opens the browser for authentication, asks what you are building,
14
+ then prints the watch URL. Exiting the wrapped shell ends the broadcast.
15
+
16
+ `--unlisted` keeps the live session and its replay out of public directories;
17
+ only people with the direct link can find it. `--no-prompts` prevents the CLI
18
+ from reading or transmitting Claude Code transcript prompts for that run.
19
+
20
+ Secret masking is defense in depth, not a guarantee. Review what your terminal
21
+ may display and delete a replay immediately if sensitive output escapes.
22
+
23
+ Requires Node.js 22 or newer and an interactive terminal.
package/dist/index.js ADDED
@@ -0,0 +1,990 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { readFileSync as readFileSync2 } from "node:fs";
5
+ import { randomBytes as randomBytes2, randomUUID } from "node:crypto";
6
+ import { createInterface } from "node:readline";
7
+
8
+ // src/config.ts
9
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ var DEFAULT_SERVER = "http://localhost:4000";
13
+ function configDir() {
14
+ return process.env.VIBESTREAM_CONFIG_DIR || join(homedir(), ".vibestream");
15
+ }
16
+ function readFile() {
17
+ try {
18
+ const v = JSON.parse(readFileSync(join(configDir(), "config.json"), "utf8"));
19
+ if (v && typeof v === "object" && !Array.isArray(v)) return v;
20
+ } catch {
21
+ }
22
+ return {};
23
+ }
24
+ function loadConfig() {
25
+ const file = readFile();
26
+ const server = process.env.VIBESTREAM_SERVER || (typeof file.server === "string" ? file.server : DEFAULT_SERVER);
27
+ return {
28
+ server,
29
+ ...typeof file.token === "string" ? { token: file.token } : {}
30
+ };
31
+ }
32
+ function saveConfig(partial) {
33
+ mkdirSync(configDir(), { recursive: true, mode: 448 });
34
+ const merged = { ...readFile(), ...partial };
35
+ const path2 = join(configDir(), "config.json");
36
+ writeFileSync(path2, JSON.stringify(merged, null, 2) + "\n", { mode: 384 });
37
+ chmodSync(path2, 384);
38
+ }
39
+
40
+ // src/delete.ts
41
+ async function deleteStream(server, id, token) {
42
+ if (!token) throw new Error("not logged in \u2014 run: vibestream login first");
43
+ const base = server.replace(/\/+$/, "");
44
+ let res;
45
+ try {
46
+ res = await fetch(`${base}/api/streams/${encodeURIComponent(id)}`, {
47
+ method: "DELETE",
48
+ headers: { authorization: `Bearer ${token}` }
49
+ });
50
+ } catch {
51
+ throw new Error(`could not reach ${base}`);
52
+ }
53
+ if (res.status === 204) return `stream ${id} deleted \u2014 log removed`;
54
+ if (res.status === 401) throw new Error("invalid or expired login \u2014 run: vibestream login");
55
+ if (res.status === 403) throw new Error("not your stream");
56
+ if (res.status === 404) {
57
+ const body = await res.json().catch(() => null);
58
+ if (body?.detail === "unknown stream") throw new Error(`no such stream: ${id}`);
59
+ throw new Error("the server has auth disabled \u2014 it has no delete endpoint");
60
+ }
61
+ throw new Error(`delete failed (HTTP ${res.status})`);
62
+ }
63
+
64
+ // src/login.ts
65
+ import { spawn } from "node:child_process";
66
+ import { randomBytes } from "node:crypto";
67
+ var AuthDisabledError = class extends Error {
68
+ constructor() {
69
+ super("the server has auth disabled \u2014 it needs DATABASE_URL and GitHub OAuth credentials");
70
+ this.name = "AuthDisabledError";
71
+ }
72
+ };
73
+ async function resolveToken({
74
+ cfg,
75
+ login: login2,
76
+ log = console.log
77
+ }) {
78
+ if (cfg.token) return { token: cfg.token };
79
+ log("first run \u2014 signing you in with GitHub\u2026");
80
+ try {
81
+ return { token: await login2() };
82
+ } catch (err) {
83
+ if (err instanceof AuthDisabledError) {
84
+ return { token: "dev", note: "server has auth disabled \u2014 streaming as guest" };
85
+ }
86
+ throw err;
87
+ }
88
+ }
89
+ function ingestErrorMessage(msg) {
90
+ const m = msg;
91
+ return m && typeof m === "object" && m.type === "error" && typeof m.message === "string" ? m.message : null;
92
+ }
93
+ async function login(server, opts = {}) {
94
+ const { pollMs = 2e3, timeoutMs = 5 * 6e4, openBrowser = true, log = console.log } = opts;
95
+ const base = server.replace(/\/+$/, "");
96
+ const nonce = randomBytes(24).toString("base64url");
97
+ const url = `${base}/cli-auth?code=${nonce}`;
98
+ let announced = false;
99
+ const announce = () => {
100
+ if (announced) return;
101
+ announced = true;
102
+ log(`Open this URL to authorize the CLI:
103
+
104
+ ${url}
105
+ `);
106
+ if (openBrowser) {
107
+ try {
108
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
109
+ const child = spawn(cmd, [url], { detached: true, stdio: "ignore" });
110
+ child.on("error", () => {
111
+ });
112
+ child.unref();
113
+ } catch {
114
+ }
115
+ }
116
+ log("Waiting for authorization...");
117
+ };
118
+ const deadline = Date.now() + timeoutMs;
119
+ while (Date.now() < deadline) {
120
+ let res = null;
121
+ let body = null;
122
+ try {
123
+ res = await fetch(`${base}/api/cli-auth/${nonce}`);
124
+ body = await res.json();
125
+ } catch {
126
+ }
127
+ if (res?.ok && typeof body?.token === "string" && body.token) {
128
+ saveConfig({ token: body.token });
129
+ log("Logged in \u2014 token saved to ~/.vibestream/config.json");
130
+ return body.token;
131
+ }
132
+ if (res?.status === 404 && body?.error !== "pending") {
133
+ throw new AuthDisabledError();
134
+ }
135
+ announce();
136
+ await new Promise((r) => setTimeout(r, pollMs));
137
+ }
138
+ throw new Error("login timed out \u2014 the authorization was never approved");
139
+ }
140
+
141
+ // src/options.ts
142
+ var CliUsageError = class extends Error {
143
+ };
144
+ function parseStreamOptions(argv) {
145
+ let visibility = "public";
146
+ let visibilityFlag = null;
147
+ let sharePrompts = true;
148
+ const title = [];
149
+ let positionalOnly = false;
150
+ const chooseVisibility = (next, flag) => {
151
+ if (visibilityFlag && visibility !== next) {
152
+ throw new CliUsageError(`cannot combine ${visibilityFlag} with ${flag}`);
153
+ }
154
+ visibility = next;
155
+ visibilityFlag = flag;
156
+ };
157
+ for (let i = 0; i < argv.length; i += 1) {
158
+ const arg = argv[i];
159
+ if (positionalOnly) {
160
+ title.push(arg);
161
+ continue;
162
+ }
163
+ if (arg === "--") {
164
+ positionalOnly = true;
165
+ } else if (arg === "--unlisted") {
166
+ chooseVisibility("unlisted", arg);
167
+ } else if (arg === "--public") {
168
+ chooseVisibility("public", arg);
169
+ } else if (arg === "--no-prompts") {
170
+ sharePrompts = false;
171
+ } else if (arg === "--visibility") {
172
+ const value = argv[++i];
173
+ if (value !== "public" && value !== "unlisted") {
174
+ throw new CliUsageError("--visibility must be public or unlisted");
175
+ }
176
+ chooseVisibility(value, `--visibility ${value}`);
177
+ } else if (arg.startsWith("--visibility=")) {
178
+ const value = arg.slice("--visibility=".length);
179
+ if (value !== "public" && value !== "unlisted") {
180
+ throw new CliUsageError("--visibility must be public or unlisted");
181
+ }
182
+ chooseVisibility(value, arg);
183
+ } else if (arg.startsWith("-")) {
184
+ throw new CliUsageError(`unknown option: ${arg}`);
185
+ } else {
186
+ title.push(arg);
187
+ }
188
+ }
189
+ const joined = title.join(" ").trim();
190
+ return { title: joined || void 0, visibility, sharePrompts };
191
+ }
192
+
193
+ // src/sender.ts
194
+ import WebSocket from "ws";
195
+ var BUFFER_MAX = 5e3;
196
+ var BUFFER_MAX_BYTES = 8 * 1024 * 1024;
197
+ var SEQUENCED_TYPES = /* @__PURE__ */ new Set(["out", "prompt", "resize"]);
198
+ var Sender = class {
199
+ constructor(url, opts = {}) {
200
+ this.url = url;
201
+ this.opts = opts;
202
+ this.connect();
203
+ }
204
+ url;
205
+ opts;
206
+ ws = null;
207
+ /** Failed writes awaiting re-delivery — older than everything in `queue`. */
208
+ retryQ = [];
209
+ /** Messages queued while disconnected (FIFO). */
210
+ queue = [];
211
+ /** Official terminal events retained until the server cumulatively acks
212
+ * their sequence. This closes the TCP handoff gap: a successful ws.send()
213
+ * callback does not prove the server processed the frame. */
214
+ pending = /* @__PURE__ */ new Map();
215
+ nextSeq = 1;
216
+ bufferedBytes = 0;
217
+ // summed string length across retryQ + queue + pending
218
+ closed = false;
219
+ retryTimer = null;
220
+ /** Consecutive failed connections; resets on a successful open. */
221
+ attempt = 0;
222
+ onStatus = () => {
223
+ };
224
+ /** Server→CLI replies (e.g. {"type":"ready","streamId"}), parsed JSON. */
225
+ onMessage = () => {
226
+ };
227
+ connect() {
228
+ if (this.closed) return;
229
+ const ws = new WebSocket(this.url, { handshakeTimeout: 1e4 });
230
+ this.ws = ws;
231
+ ws.on("open", () => {
232
+ this.attempt = 0;
233
+ const pending = [...this.pending.values()];
234
+ this.emitStatus("connected");
235
+ const batch = [...this.retryQ, ...this.queue];
236
+ this.retryQ = [];
237
+ this.queue = [];
238
+ for (const m of batch) {
239
+ this.bufferedBytes -= m.length;
240
+ this.write(ws, m, true);
241
+ }
242
+ for (const m of pending) this.write(ws, m, false);
243
+ });
244
+ const retry = () => {
245
+ if (this.closed) return;
246
+ this.ws = null;
247
+ this.retryTimer = setTimeout(() => {
248
+ this.retryTimer = null;
249
+ this.connect();
250
+ }, this.nextDelay());
251
+ this.emitStatus("reconnecting");
252
+ };
253
+ ws.on("close", retry);
254
+ ws.on("error", () => {
255
+ });
256
+ ws.on("message", (raw) => {
257
+ let msg;
258
+ try {
259
+ msg = JSON.parse(String(raw));
260
+ } catch {
261
+ return;
262
+ }
263
+ const reply = msg;
264
+ if (reply?.type === "ack") this.ack(reply.seq);
265
+ else if (reply?.type === "ready") this.ack(reply.ackSeq);
266
+ if (reply?.type === "ack") return;
267
+ try {
268
+ this.onMessage(msg);
269
+ } catch {
270
+ }
271
+ });
272
+ }
273
+ /**
274
+ * Exponential backoff with jitter: min(base * 2^attempt, 30s) ± up to 30%.
275
+ * Jitter de-synchronizes a fleet of clients hammering a server that just
276
+ * came back. The counter resets on a successful open.
277
+ */
278
+ nextDelay() {
279
+ const base = this.opts.reconnectMs ?? 1e3;
280
+ const capped = Math.min(base * 2 ** this.attempt, 3e4);
281
+ this.attempt++;
282
+ const jitter = capped * 0.3 * (Math.random() * 2 - 1);
283
+ return Math.max(0, Math.round(capped + jitter));
284
+ }
285
+ /**
286
+ * Send with delivery feedback: a local write failure re-buffers the message
287
+ * in retryQ (order preserved — ws invokes send callbacks in send order).
288
+ * Frames already handed to TCP can still be lost silently on a dying
289
+ * connection; only app-level acks (Task 17) can cover that gap.
290
+ */
291
+ write(ws, raw, retryOnError) {
292
+ ws.send(raw, (err) => {
293
+ if (err && retryOnError) {
294
+ this.retryQ.push(raw);
295
+ this.bufferedBytes += raw.length;
296
+ this.evict();
297
+ }
298
+ });
299
+ }
300
+ send(msg) {
301
+ if (this.closed) return;
302
+ if (SEQUENCED_TYPES.has(msg.type)) {
303
+ const seq = this.nextSeq++;
304
+ const raw2 = JSON.stringify({ ...msg, seq });
305
+ this.pending.set(seq, raw2);
306
+ this.bufferedBytes += raw2.length;
307
+ this.evict();
308
+ if (this.ws?.readyState === WebSocket.OPEN && this.pending.has(seq)) {
309
+ this.write(this.ws, raw2, false);
310
+ }
311
+ return;
312
+ }
313
+ const raw = JSON.stringify(msg);
314
+ if (this.ws?.readyState === WebSocket.OPEN) this.write(this.ws, raw, true);
315
+ else {
316
+ this.queue.push(raw);
317
+ this.bufferedBytes += raw.length;
318
+ this.evict();
319
+ }
320
+ }
321
+ /** Drop oldest while over either cap; never drop the sole (newest) message. */
322
+ evict() {
323
+ const size = () => this.retryQ.length + this.queue.length + this.pending.size;
324
+ const dropOldest = () => {
325
+ const firstPending = this.pending.entries().next().value;
326
+ if (firstPending) {
327
+ this.pending.delete(firstPending[0]);
328
+ this.bufferedBytes -= firstPending[1].length;
329
+ return;
330
+ }
331
+ const q = this.retryQ.length > 0 ? this.retryQ : this.queue;
332
+ this.bufferedBytes -= q.shift().length;
333
+ };
334
+ while (size() > 1 && size() > (this.opts.bufferMax ?? BUFFER_MAX)) dropOldest();
335
+ while (size() > 1 && this.bufferedBytes > (this.opts.bufferMaxBytes ?? BUFFER_MAX_BYTES)) dropOldest();
336
+ }
337
+ ack(value) {
338
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) return;
339
+ for (const [seq, raw] of this.pending) {
340
+ if (seq > value) break;
341
+ this.pending.delete(seq);
342
+ this.bufferedBytes -= raw.length;
343
+ }
344
+ }
345
+ emitStatus(s) {
346
+ try {
347
+ this.onStatus(s);
348
+ } catch {
349
+ }
350
+ }
351
+ close() {
352
+ this.closed = true;
353
+ if (this.retryTimer) {
354
+ clearTimeout(this.retryTimer);
355
+ this.retryTimer = null;
356
+ }
357
+ this.ws?.close();
358
+ }
359
+ };
360
+
361
+ // src/recorder.ts
362
+ import * as pty from "node-pty";
363
+
364
+ // ../shared/src/protocol.ts
365
+ var BROADCAST_TERMINAL_SIZE = Object.freeze({ cols: 120, rows: 36 });
366
+
367
+ // src/masking/patterns.ts
368
+ var PATTERNS = [
369
+ /AKIA[0-9A-Z]{16}/g,
370
+ // AWS access key id
371
+ /github_pat_[A-Za-z0-9_]{22,255}/g,
372
+ // GitHub fine-grained PAT
373
+ /gh[pousr]_[A-Za-z0-9]{36,255}/g,
374
+ // GitHub tokens
375
+ /(?<![A-Za-z0-9])sk-[A-Za-z0-9_-]{20,}/g,
376
+ // OpenAI / Anthropic-style
377
+ /(?<![A-Za-z0-9])sk_(live|test)_[A-Za-z0-9]{16,}/g,
378
+ // Stripe
379
+ /xox[baprs]-[A-Za-z0-9-]{10,}/g,
380
+ // Slack
381
+ /eyJ[A-Za-z0-9_-]{10,4096}\.[A-Za-z0-9_-]{10,4096}\.[A-Za-z0-9_-]{10,4096}/g,
382
+ // JWT
383
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
384
+ // PEM whole block
385
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/g,
386
+ // PEM header (fallback for chunk-split blocks)
387
+ /AIza[0-9A-Za-z_-]{35}/g
388
+ // Google API key
389
+ ];
390
+ function maskKnownSecrets(text) {
391
+ let out = text;
392
+ for (const re of PATTERNS) out = out.replace(re, "[MASKED]");
393
+ return out;
394
+ }
395
+
396
+ // src/masking/entropy.ts
397
+ function entropy(s) {
398
+ const freq = /* @__PURE__ */ new Map();
399
+ for (const c of s) freq.set(c, (freq.get(c) ?? 0) + 1);
400
+ let h = 0;
401
+ for (const n of freq.values()) {
402
+ const p = n / s.length;
403
+ h -= p * Math.log2(p);
404
+ }
405
+ return h;
406
+ }
407
+ var CANDIDATE = /[A-Za-z0-9+/_-]{32,}/g;
408
+ var HEX_ONLY = /^[0-9a-f]+$/i;
409
+ var SRI_PREFIX = /^sha(256|384|512)-/;
410
+ function upperFraction(s) {
411
+ let upper = 0, lower = 0;
412
+ for (const c of s) {
413
+ if (c >= "A" && c <= "Z") upper++;
414
+ else if (c >= "a" && c <= "z") lower++;
415
+ }
416
+ const letters = upper + lower;
417
+ return letters === 0 ? 0 : upper / letters;
418
+ }
419
+ function digitCount(s) {
420
+ let n = 0;
421
+ for (const c of s) if (c >= "0" && c <= "9") n++;
422
+ return n;
423
+ }
424
+ var T_LOW = 4;
425
+ var T_HIGH = 4.6;
426
+ var LONG = 40;
427
+ function maskHighEntropy(text) {
428
+ return text.replace(CANDIDATE, (tok) => {
429
+ if (HEX_ONLY.test(tok)) return tok;
430
+ if (SRI_PREFIX.test(tok)) return tok;
431
+ const hasUpper = /[A-Z]/.test(tok), hasLower = /[a-z]/.test(tok), hasDigit = /\d/.test(tok);
432
+ if (!(hasUpper && hasLower && hasDigit)) return tok;
433
+ const h = entropy(tok);
434
+ if (h < T_LOW) return tok;
435
+ if (tok.length < LONG || h >= T_HIGH) return "[MASKED]";
436
+ const uf = upperFraction(tok);
437
+ return uf >= 0.3 && uf <= 0.7 || digitCount(tok) >= 5 ? "[MASKED]" : tok;
438
+ });
439
+ }
440
+
441
+ // src/masking/stream.ts
442
+ var TAIL_MAX = 512;
443
+ var PEM_SUPPRESS_MAX = 16 * 1024;
444
+ var PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
445
+ var PEM_END = /-----END [A-Z ]*PRIVATE KEY-----/;
446
+ function maskAll(s) {
447
+ return maskHighEntropy(maskKnownSecrets(s));
448
+ }
449
+ var StreamMasker = class {
450
+ constructor(quietMs = 50) {
451
+ this.quietMs = quietMs;
452
+ }
453
+ quietMs;
454
+ tail = "";
455
+ inPem = false;
456
+ pemDropped = 0;
457
+ timer = null;
458
+ sink = null;
459
+ pending = [];
460
+ // Single subscriber; a later call replaces the earlier one. Masked output
461
+ // produced before the first subscription is buffered and replayed here so
462
+ // early writes are never silently dropped.
463
+ onData(fn) {
464
+ this.sink = fn;
465
+ const held = this.pending;
466
+ this.pending = [];
467
+ for (const s of held) fn(s);
468
+ }
469
+ deliver(s) {
470
+ if (this.sink) this.sink(s);
471
+ else this.pending.push(s);
472
+ }
473
+ // A chunk-split PEM block never reaches maskAll in one string, so the
474
+ // whole-block regex in patterns.ts can't fire and the entropy pass misses
475
+ // short body lines (e.g. a trailing "cQ=="). This carries "inside a PEM
476
+ // block" across emits: the BEGIN header is kept (the patterns pass masks
477
+ // it), everything through the END marker is dropped. Suppression is bounded
478
+ // by PEM_SUPPRESS_MAX so a stray BEGIN line in ordinary output cannot eat
479
+ // the rest of the session.
480
+ stripPemInterior(s) {
481
+ let out = "";
482
+ let rest = s;
483
+ while (rest) {
484
+ if (this.inPem) {
485
+ const m = PEM_END.exec(rest);
486
+ if (!m) {
487
+ if (this.pemDropped + rest.length > PEM_SUPPRESS_MAX) {
488
+ this.inPem = false;
489
+ this.pemDropped = 0;
490
+ out += "[MASKED]";
491
+ continue;
492
+ }
493
+ this.pemDropped += rest.length;
494
+ return out;
495
+ }
496
+ this.inPem = false;
497
+ this.pemDropped = 0;
498
+ rest = rest.slice(m.index + m[0].length);
499
+ } else {
500
+ const m = PEM_BEGIN.exec(rest);
501
+ if (!m) return out + rest;
502
+ const keep = m.index + m[0].length;
503
+ out += rest.slice(0, keep);
504
+ rest = rest.slice(keep);
505
+ this.inPem = true;
506
+ this.pemDropped = 0;
507
+ }
508
+ }
509
+ return out;
510
+ }
511
+ emit(raw) {
512
+ const masked = maskAll(this.stripPemInterior(raw));
513
+ if (masked) this.deliver(masked);
514
+ }
515
+ write(chunk) {
516
+ if (this.timer) {
517
+ clearTimeout(this.timer);
518
+ this.timer = null;
519
+ }
520
+ const buf = this.tail + chunk;
521
+ let cut = Math.max(buf.lastIndexOf("\n"), buf.lastIndexOf("\r"));
522
+ if (buf.length - (cut + 1) > TAIL_MAX) {
523
+ cut = Math.max(cut, buf.lastIndexOf(" "), buf.lastIndexOf(" "));
524
+ }
525
+ let emit;
526
+ if (cut === -1 && buf.length <= TAIL_MAX) {
527
+ emit = "";
528
+ this.tail = buf;
529
+ } else if (cut === -1) {
530
+ emit = buf;
531
+ this.tail = "";
532
+ } else {
533
+ emit = buf.slice(0, cut + 1);
534
+ this.tail = buf.slice(cut + 1);
535
+ }
536
+ if (emit) this.emit(emit);
537
+ if (this.tail) this.timer = setTimeout(() => this.flush(), this.quietMs);
538
+ }
539
+ // Clean shutdown: call flush() to deliver whatever can safely be delivered
540
+ // (then dispose() if the instance is being abandoned). While the PEM guard
541
+ // is armed and the tail lacks an intact END marker, flush HOLDS the tail
542
+ // instead of emitting: running a partial END through stripPemInterior would
543
+ // drop it and leave the guard armed forever (the split-END blackout). The
544
+ // next write — or the suppression cap — resolves it; a stream that dies
545
+ // mid-key stays masked.
546
+ flush() {
547
+ if (this.timer) {
548
+ clearTimeout(this.timer);
549
+ this.timer = null;
550
+ }
551
+ if (this.inPem && !PEM_END.test(this.tail)) return;
552
+ if (this.tail) {
553
+ const t = this.tail;
554
+ this.tail = "";
555
+ this.emit(t);
556
+ }
557
+ }
558
+ // Abandon path: discards the held tail WITHOUT emitting (fail-closed) and
559
+ // stops the timer. Use flush() first when the tail should still be
560
+ // delivered.
561
+ dispose() {
562
+ if (this.timer) {
563
+ clearTimeout(this.timer);
564
+ this.timer = null;
565
+ }
566
+ this.tail = "";
567
+ }
568
+ };
569
+
570
+ // src/recorder.ts
571
+ function wirePipeline(sender, quietMs = 50) {
572
+ const masker = new StreamMasker(quietMs);
573
+ masker.onData((s) => sender.send({ type: "out", data: Buffer.from(s, "utf8").toString("base64") }));
574
+ return { onPtyData: (d) => masker.write(d), flush: () => masker.flush() };
575
+ }
576
+ function record(sender, onExit, deps = {}) {
577
+ const env = deps.env ?? process.env;
578
+ const stdin = deps.stdin ?? process.stdin;
579
+ const stdout = deps.stdout ?? process.stdout;
580
+ const shell = env.SHELL || "/bin/zsh";
581
+ const p = (deps.spawn ?? pty.spawn)(shell, [], {
582
+ name: "xterm-256color",
583
+ ...BROADCAST_TERMINAL_SIZE,
584
+ cwd: deps.cwd ?? process.cwd(),
585
+ // ioctl(TIOCGWINSZ) is authoritative, but override inherited hints too so
586
+ // programs that consult the environment before the first ioctl agree.
587
+ env: {
588
+ ...env,
589
+ COLUMNS: String(BROADCAST_TERMINAL_SIZE.cols),
590
+ LINES: String(BROADCAST_TERMINAL_SIZE.rows)
591
+ }
592
+ });
593
+ const pipe = wirePipeline(sender);
594
+ p.onData((d) => {
595
+ stdout.write(d);
596
+ pipe.onPtyData(d);
597
+ });
598
+ p.onExit(({ exitCode }) => {
599
+ pipe.flush();
600
+ onExit(exitCode);
601
+ });
602
+ stdin.setRawMode(true);
603
+ stdin.setEncoding("utf8");
604
+ stdin.on("data", (d) => p.write(d));
605
+ return { pty: p, flush: pipe.flush };
606
+ }
607
+
608
+ // src/prompts/claudeCode.ts
609
+ import * as fs from "node:fs";
610
+ import * as os from "node:os";
611
+ import * as path from "node:path";
612
+ function escapeCwd(cwd) {
613
+ return cwd.replace(/[^A-Za-z0-9]/g, "-");
614
+ }
615
+ var MAX_READ_BYTES = 1024 * 1024;
616
+ var MACHINE_TEXT_PREFIXES = [
617
+ "<command-name>",
618
+ // slash-command echo (/clear etc.)
619
+ "<local-command-",
620
+ // local-command stdout/stderr blocks
621
+ "<task-notification>",
622
+ // background task/agent completion notices (task-id/tool-use-id blobs)
623
+ "<bash-input>",
624
+ // bash-mode (!) command echo
625
+ "<bash-stdout>",
626
+ // bash-mode output echo
627
+ "<bash-stderr>"
628
+ // defensively: stderr-first output echo
629
+ ];
630
+ function startClaudeCodeWatcher(opts) {
631
+ const { sender } = opts;
632
+ const projectsDir = opts.projectsDir ?? path.join(os.homedir(), ".claude", "projects");
633
+ const dir = path.join(projectsDir, escapeCwd(opts.cwd ?? process.cwd()));
634
+ const sessionStart = opts.sessionStart ?? Date.now();
635
+ const states = /* @__PURE__ */ new Map();
636
+ const findNewest = () => {
637
+ let names;
638
+ try {
639
+ names = fs.readdirSync(dir);
640
+ } catch {
641
+ return null;
642
+ }
643
+ let best = null;
644
+ let bestMtime = -1;
645
+ for (const name of names) {
646
+ if (!name.endsWith(".jsonl")) continue;
647
+ const p = path.join(dir, name);
648
+ let st;
649
+ try {
650
+ st = fs.statSync(p);
651
+ } catch {
652
+ continue;
653
+ }
654
+ if (!st.isFile() || st.mtimeMs < sessionStart) continue;
655
+ if (st.mtimeMs > bestMtime || st.mtimeMs === bestMtime && (best === null || p > best)) {
656
+ bestMtime = st.mtimeMs;
657
+ best = p;
658
+ }
659
+ }
660
+ return best;
661
+ };
662
+ const tail = (file) => {
663
+ let st;
664
+ try {
665
+ st = fs.statSync(file);
666
+ } catch {
667
+ return;
668
+ }
669
+ let { offset, skip } = states.get(file) ?? { offset: 0, skip: false };
670
+ if (st.size < offset) {
671
+ offset = 0;
672
+ skip = false;
673
+ }
674
+ if (st.size <= offset) return;
675
+ const want = Math.min(st.size - offset, MAX_READ_BYTES);
676
+ let buf;
677
+ let fd = null;
678
+ try {
679
+ fd = fs.openSync(file, "r");
680
+ const raw = new Uint8Array(want);
681
+ const n = fs.readSync(fd, raw, 0, want, offset);
682
+ buf = Buffer.from(raw.buffer, 0, n);
683
+ } catch {
684
+ return;
685
+ } finally {
686
+ if (fd !== null) fs.closeSync(fd);
687
+ }
688
+ const lastNL = buf.lastIndexOf(10);
689
+ if (lastNL === -1) {
690
+ if (skip || buf.length === MAX_READ_BYTES) states.set(file, { offset: offset + buf.length, skip: true });
691
+ return;
692
+ }
693
+ const start = skip ? buf.indexOf(10) + 1 : 0;
694
+ states.set(file, { offset: offset + lastNL + 1, skip: false });
695
+ for (const line of buf.subarray(start, lastNL).toString("utf8").split("\n")) {
696
+ handleLine(line);
697
+ }
698
+ };
699
+ const handleLine = (line) => {
700
+ if (!line.trim()) return;
701
+ let entry;
702
+ try {
703
+ entry = JSON.parse(line);
704
+ } catch {
705
+ return;
706
+ }
707
+ if (entry?.type !== "user") return;
708
+ if (entry.isMeta === true || entry.isCompactSummary === true || entry.isVisibleInTranscriptOnly === true) {
709
+ return;
710
+ }
711
+ const content = entry.message?.content;
712
+ let text;
713
+ if (typeof content === "string") text = content;
714
+ else if (Array.isArray(content)) {
715
+ text = content.filter((p) => p?.type === "text" && typeof p.text === "string").map((p) => p.text).join("\n");
716
+ } else return;
717
+ const trimmed = text.trim();
718
+ if (!trimmed) return;
719
+ if (MACHINE_TEXT_PREFIXES.some((p) => trimmed.startsWith(p))) return;
720
+ if (trimmed === "[Request interrupted by user]" || trimmed === "[Request interrupted by user for tool use]") {
721
+ return;
722
+ }
723
+ const ts = typeof entry.timestamp === "string" && Date.parse(entry.timestamp) || Date.now();
724
+ sender.send({ type: "prompt", text: maskAll(text), ts });
725
+ };
726
+ const tick = () => {
727
+ try {
728
+ const file = findNewest();
729
+ if (file) tail(file);
730
+ } catch {
731
+ }
732
+ };
733
+ const timer = setInterval(tick, opts.pollMs ?? 500);
734
+ timer.unref?.();
735
+ return { stop: () => clearInterval(timer) };
736
+ }
737
+
738
+ // src/index.ts
739
+ var TITLE_LIVE = "\x1B]0;\u25CF LIVE \u2014 vibestream\x07";
740
+ var TITLE_RECONNECTING = "\x1B]0;\u27F3 RECONNECTING \u2014 vibestream\x07";
741
+ var TITLE_OFFLINE = "\x1B]0;vibestream (offline)\x07";
742
+ var TITLE_CLEAR = "\x1B]0;\x07";
743
+ function packageVersion() {
744
+ try {
745
+ const manifest = JSON.parse(
746
+ readFileSync2(new URL("../package.json", import.meta.url), "utf8")
747
+ );
748
+ return typeof manifest.version === "string" ? manifest.version : "unknown";
749
+ } catch {
750
+ return "unknown";
751
+ }
752
+ }
753
+ function printHelp() {
754
+ console.log(`vibestreams \u2014 broadcast a read-only coding terminal
755
+
756
+ Usage:
757
+ vibestreams [options] [title] start a stream
758
+ vibestreams login connect your GitHub account
759
+ vibestreams delete <id> permanently delete a replay
760
+
761
+ Options:
762
+ --unlisted hide this stream and replay from directories
763
+ --no-prompts do not read or share Claude transcript prompts
764
+ --visibility <mode> public or unlisted (default: public)
765
+ -h, --help show this help
766
+ -v, --version show the installed version
767
+
768
+ The installed \`vibestream\` command is an alias of \`vibestreams\`.`);
769
+ }
770
+ function restoreStdin() {
771
+ try {
772
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
773
+ } catch {
774
+ }
775
+ process.stdin.pause();
776
+ }
777
+ function ask(q) {
778
+ return new Promise((resolve) => {
779
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
780
+ let answered = false;
781
+ rl.on("SIGINT", () => rl.close());
782
+ rl.on("close", () => {
783
+ if (!answered) {
784
+ process.stdout.write("\n");
785
+ process.exit(130);
786
+ }
787
+ });
788
+ rl.question(q, (a) => {
789
+ answered = true;
790
+ rl.close();
791
+ resolve(a.trim());
792
+ });
793
+ });
794
+ }
795
+ async function main() {
796
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
797
+ printHelp();
798
+ return;
799
+ }
800
+ if (process.argv.includes("--version") || process.argv.includes("-v")) {
801
+ console.log(packageVersion());
802
+ return;
803
+ }
804
+ const cfg = loadConfig();
805
+ const server = cfg.server.replace(/\/+$/, "");
806
+ if (!/^https?:\/\//.test(server)) {
807
+ const source = process.env.VIBESTREAM_SERVER ? "VIBESTREAM_SERVER" : "~/.vibestream/config.json";
808
+ console.error(
809
+ `vibestream: invalid server URL "${cfg.server}" (from ${source}) \u2014 must start with http:// or https://`
810
+ );
811
+ process.exit(1);
812
+ }
813
+ if (process.argv[2] === "login") {
814
+ try {
815
+ await login(server);
816
+ process.exit(0);
817
+ } catch (err) {
818
+ console.error(`vibestream: ${err instanceof Error ? err.message : err}`);
819
+ process.exit(1);
820
+ }
821
+ }
822
+ if (process.argv[2] === "delete") {
823
+ const id = process.argv[3];
824
+ if (!id) {
825
+ console.error("usage: vibestream delete <streamId>");
826
+ process.exit(1);
827
+ }
828
+ try {
829
+ console.log(await deleteStream(server, id, cfg.token));
830
+ process.exit(0);
831
+ } catch (err) {
832
+ console.error(`vibestream: ${err instanceof Error ? err.message : err}`);
833
+ process.exit(1);
834
+ }
835
+ }
836
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
837
+ console.error("vibestream needs an interactive terminal");
838
+ process.exit(1);
839
+ }
840
+ let token;
841
+ try {
842
+ const resolved = await resolveToken({
843
+ cfg,
844
+ login: () => login(server)
845
+ });
846
+ token = resolved.token;
847
+ if (resolved.note) console.log(resolved.note);
848
+ } catch (err) {
849
+ console.error(`vibestream: ${err instanceof Error ? err.message : err}`);
850
+ process.exit(1);
851
+ }
852
+ let streamOptions;
853
+ try {
854
+ streamOptions = parseStreamOptions(process.argv.slice(2));
855
+ } catch (err) {
856
+ if (!(err instanceof CliUsageError)) throw err;
857
+ console.error(`vibestream: ${err.message}
858
+ Run \`vibestreams --help\` for usage.`);
859
+ process.exit(1);
860
+ }
861
+ const title = (streamOptions.title ?? await ask("What are you building? ")) || "untitled";
862
+ const wsUrl = server.replace(/^http/, "ws") + "/ingest";
863
+ const sender = new Sender(wsUrl);
864
+ const sessionId = randomUUID();
865
+ const resumeToken = randomBytes2(32).toString("base64url");
866
+ let exiting = false;
867
+ const DEV_SERVER = "http://localhost:4000";
868
+ const DEV_WEB = "http://localhost:5173";
869
+ const webBase = (process.env.VIBESTREAM_WEB_URL || (server === DEV_SERVER ? DEV_WEB : server)).replace(/\/+$/, "");
870
+ let lastStreamId = null;
871
+ sender.onMessage = (msg) => {
872
+ const ingestError = ingestErrorMessage(msg);
873
+ if (ingestError) {
874
+ process.stdout.write(`\r
875
+ vibestream: ${ingestError}\r
876
+ `);
877
+ if (cfg.token && ingestError.startsWith("invalid token")) {
878
+ process.stdout.write("your saved login may have expired \u2014 run: vibestream login\r\n");
879
+ }
880
+ shutdown(1);
881
+ return;
882
+ }
883
+ const m = msg;
884
+ if (!m || m.type !== "ready" || typeof m.streamId !== "string") return;
885
+ if (m.streamId === lastStreamId) return;
886
+ lastStreamId = m.streamId;
887
+ process.stdout.write(`\r
888
+ \u25CF watching at ${webBase}/s/${m.streamId}\r
889
+ `);
890
+ };
891
+ const maxOfflineMs = Number(process.env.VIBESTREAM_MAX_OFFLINE_MS) || 10 * 6e4;
892
+ let giveUpTimer = null;
893
+ let gaveUp = false;
894
+ const giveUp = () => {
895
+ gaveUp = true;
896
+ sender.close();
897
+ process.stdout.write(TITLE_OFFLINE);
898
+ process.stdout.write(
899
+ "\r\nvibestream: connection lost \u2014 stream ended; your shell keeps working\r\n"
900
+ );
901
+ };
902
+ const armGiveUp = () => {
903
+ if (!giveUpTimer && !gaveUp) giveUpTimer = setTimeout(giveUp, maxOfflineMs).unref();
904
+ };
905
+ sender.onStatus = (s) => {
906
+ if (gaveUp || exiting) return;
907
+ if (s === "connected") {
908
+ if (giveUpTimer) {
909
+ clearTimeout(giveUpTimer);
910
+ giveUpTimer = null;
911
+ }
912
+ sender.send({
913
+ type: "hello",
914
+ token,
915
+ title,
916
+ agent: "claude-code",
917
+ sessionId,
918
+ resumeToken,
919
+ visibility: streamOptions.visibility
920
+ });
921
+ process.stdout.write(TITLE_LIVE);
922
+ } else {
923
+ armGiveUp();
924
+ process.stdout.write(TITLE_RECONNECTING);
925
+ }
926
+ };
927
+ process.stdout.write(TITLE_RECONNECTING);
928
+ sender.send({ type: "resize", ...BROADCAST_TERMINAL_SIZE });
929
+ armGiveUp();
930
+ const privacy = [
931
+ streamOptions.visibility === "unlisted" ? "unlisted" : null,
932
+ !streamOptions.sharePrompts ? "prompts off" : null
933
+ ].filter(Boolean);
934
+ console.log(
935
+ `\u25CF LIVE \u2014 streaming at ${BROADCAST_TERMINAL_SIZE.cols}\xD7${BROADCAST_TERMINAL_SIZE.rows}${privacy.length ? ` (${privacy.join(", ")})` : ""}. Type 'exit' to stop.`
936
+ );
937
+ let flushMasker = null;
938
+ let stopPromptWatcher = null;
939
+ const shutdown = (code) => {
940
+ if (exiting) return;
941
+ exiting = true;
942
+ if (giveUpTimer) {
943
+ clearTimeout(giveUpTimer);
944
+ giveUpTimer = null;
945
+ }
946
+ stopPromptWatcher?.();
947
+ restoreStdin();
948
+ process.stdout.write(TITLE_CLEAR);
949
+ flushMasker?.();
950
+ sender.send({ type: "end" });
951
+ setTimeout(() => {
952
+ sender.close();
953
+ process.exit(code);
954
+ }, 300);
955
+ };
956
+ process.stdin.on("error", () => shutdown(1));
957
+ process.on("SIGINT", () => {
958
+ });
959
+ process.on("SIGTERM", () => shutdown(143));
960
+ process.on("SIGHUP", () => shutdown(129));
961
+ try {
962
+ const rec = record(sender, (code) => shutdown(code));
963
+ flushMasker = rec.flush;
964
+ } catch (err) {
965
+ restoreStdin();
966
+ const detail = err instanceof Error ? err.message : String(err);
967
+ console.error(
968
+ `vibestream: could not start your shell (${process.env.SHELL || "/bin/zsh"}): ${detail}`
969
+ );
970
+ sender.close();
971
+ process.exit(1);
972
+ }
973
+ if (streamOptions.sharePrompts) {
974
+ try {
975
+ stopPromptWatcher = startClaudeCodeWatcher({
976
+ sender,
977
+ // Test/dev seam — lets smokes point the watcher at a scratch dir
978
+ // instead of the real ~/.claude. Unset (the normal case) falls through
979
+ // to the watcher's own ~/.claude/projects default.
980
+ projectsDir: process.env.VIBESTREAM_CLAUDE_PROJECTS_DIR || void 0
981
+ }).stop;
982
+ } catch {
983
+ }
984
+ }
985
+ }
986
+ main().catch((err) => {
987
+ restoreStdin();
988
+ console.error(`vibestream: unexpected error: ${err instanceof Error ? err.message : err}`);
989
+ process.exit(1);
990
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "vibestreams",
3
+ "version": "0.1.0",
4
+ "description": "Broadcast a safe, read-only coding terminal to Vibestreams.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "vibestream": "dist/index.js",
9
+ "vibestreams": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist/index.js",
13
+ "scripts/fix-node-pty-perms.mjs"
14
+ ],
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/cyzanfar/vibestreams-platform.git",
24
+ "directory": "packages/cli"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc --noEmit && esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/index.js --external:node-pty --external:ws",
28
+ "test": "vitest run --passWithNoTests",
29
+ "prepack": "npm run build",
30
+ "package:smoke": "node scripts/package-smoke.mjs",
31
+ "postinstall": "node scripts/fix-node-pty-perms.mjs"
32
+ },
33
+ "dependencies": {
34
+ "node-pty": "^1.1.0",
35
+ "ws": "^8.21.3"
36
+ },
37
+ "devDependencies": {
38
+ "@types/ws": "^8.18.1",
39
+ "@vibestream/shared": "workspace:*",
40
+ "esbuild": "^0.28.2"
41
+ }
42
+ }
@@ -0,0 +1,58 @@
1
+ // postinstall guard (Task 30, recorded incident): pnpm extracts node-pty's
2
+ // darwin prebuilds with spawn-helper NON-executable (tar entry modes are not
3
+ // preserved through pnpm's content-addressable store), so the very first
4
+ // `vibestream` run dies with EACCES inside posix_spawn. Restore the bit here.
5
+ //
6
+ // Plain .mjs, no build step: postinstall runs before dist/ exists. Must never
7
+ // fail an install — every path degrades to a silent no-op (missing package,
8
+ // missing prebuilds, foreign platform, unreadable file).
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { createRequire } from "node:module";
12
+ import { pathToFileURL } from "node:url";
13
+
14
+ /**
15
+ * chmod +x every prebuilds/darwin-<arch>/spawn-helper under nodePtyDir that
16
+ * is missing execute bits. Returns the list of paths it fixed (for tests and
17
+ * the one-line log below). Never throws.
18
+ */
19
+ export function fixSpawnHelperPerms(nodePtyDir) {
20
+ const fixed = [];
21
+ const prebuilds = path.join(nodePtyDir, "prebuilds");
22
+ let entries;
23
+ try {
24
+ entries = fs.readdirSync(prebuilds);
25
+ } catch {
26
+ return fixed; // no prebuilds shipped (or no package): nothing to guard
27
+ }
28
+ for (const name of entries) {
29
+ if (!name.startsWith("darwin-")) continue; // the incident is darwin-only
30
+ const helper = path.join(prebuilds, name, "spawn-helper");
31
+ try {
32
+ const mode = fs.statSync(helper).mode;
33
+ if ((mode & 0o111) !== 0o111) {
34
+ fs.chmodSync(helper, mode | 0o111);
35
+ fixed.push(helper);
36
+ }
37
+ } catch {
38
+ /* helper absent or unreadable: no-op */
39
+ }
40
+ }
41
+ return fixed;
42
+ }
43
+
44
+ // Run only as a script (postinstall), never on test import.
45
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
46
+ try {
47
+ if (process.platform === "darwin") {
48
+ const require = createRequire(import.meta.url);
49
+ const dir = path.dirname(require.resolve("node-pty/package.json"));
50
+ const fixed = fixSpawnHelperPerms(dir);
51
+ if (fixed.length > 0) {
52
+ console.log(`vibestream postinstall: restored execute bit on ${fixed.length} node-pty spawn-helper(s)`);
53
+ }
54
+ }
55
+ } catch {
56
+ /* node-pty not resolvable here (e.g. install pruned it): no-op */
57
+ }
58
+ }