@xerktech/claude-hud 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.
Files changed (60) hide show
  1. package/README.md +50 -0
  2. package/dist/_broker/attached.js +78 -0
  3. package/dist/_broker/attached.js.map +1 -0
  4. package/dist/_broker/audio-codec.js +82 -0
  5. package/dist/_broker/audio-codec.js.map +1 -0
  6. package/dist/_broker/audio-session.js +81 -0
  7. package/dist/_broker/audio-session.js.map +1 -0
  8. package/dist/_broker/auth.js +32 -0
  9. package/dist/_broker/auth.js.map +1 -0
  10. package/dist/_broker/bus.js +76 -0
  11. package/dist/_broker/bus.js.map +1 -0
  12. package/dist/_broker/cert.js +72 -0
  13. package/dist/_broker/cert.js.map +1 -0
  14. package/dist/_broker/claude.js +160 -0
  15. package/dist/_broker/claude.js.map +1 -0
  16. package/dist/_broker/cli.js +134 -0
  17. package/dist/_broker/cli.js.map +1 -0
  18. package/dist/_broker/cors.js +48 -0
  19. package/dist/_broker/cors.js.map +1 -0
  20. package/dist/_broker/hooks.js +196 -0
  21. package/dist/_broker/hooks.js.map +1 -0
  22. package/dist/_broker/index.js +48 -0
  23. package/dist/_broker/index.js.map +1 -0
  24. package/dist/_broker/intent-dispatcher.js +86 -0
  25. package/dist/_broker/intent-dispatcher.js.map +1 -0
  26. package/dist/_broker/intent.js +127 -0
  27. package/dist/_broker/intent.js.map +1 -0
  28. package/dist/_broker/jsonl-tail.js +185 -0
  29. package/dist/_broker/jsonl-tail.js.map +1 -0
  30. package/dist/_broker/mdns.js +41 -0
  31. package/dist/_broker/mdns.js.map +1 -0
  32. package/dist/_broker/projects.js +161 -0
  33. package/dist/_broker/projects.js.map +1 -0
  34. package/dist/_broker/qr.js +11 -0
  35. package/dist/_broker/qr.js.map +1 -0
  36. package/dist/_broker/routes.js +379 -0
  37. package/dist/_broker/routes.js.map +1 -0
  38. package/dist/_broker/server.js +325 -0
  39. package/dist/_broker/server.js.map +1 -0
  40. package/dist/_broker/sessions-store.js +50 -0
  41. package/dist/_broker/sessions-store.js.map +1 -0
  42. package/dist/_broker/sessions.js +792 -0
  43. package/dist/_broker/sessions.js.map +1 -0
  44. package/dist/_broker/store.js +79 -0
  45. package/dist/_broker/store.js.map +1 -0
  46. package/dist/_broker/stt.js +93 -0
  47. package/dist/_broker/stt.js.map +1 -0
  48. package/dist/broker-bin.js +37 -0
  49. package/dist/broker-bin.js.map +1 -0
  50. package/dist/broker-lifecycle.js +186 -0
  51. package/dist/broker-lifecycle.js.map +1 -0
  52. package/dist/index.js +189 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/paths.js +17 -0
  55. package/dist/paths.js.map +1 -0
  56. package/dist/wrapper/index.js +263 -0
  57. package/dist/wrapper/index.js.map +1 -0
  58. package/dist/wrapper/slug.js +5 -0
  59. package/dist/wrapper/slug.js.map +1 -0
  60. package/package.json +70 -0
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # claude-hud
2
+
3
+ Drive [Claude Code](https://code.claude.com) sessions from **Even Realities G2** smart glasses over your local network. One command on your PC spawns a broker, registers the current working directory, and runs `claude` inside a PTY so the glasses can attach — voice in, glasses out, no cloud relay.
4
+
5
+ See the [project repo](https://github.com/xerktech/claudehud) for architecture, the broker design, and the companion Even Hub plugin (`.ehpk`) that runs on your Android phone.
6
+
7
+ ## Requirements
8
+
9
+ - **Node 22 LTS** or newer (`node --version` ≥ 22)
10
+ - **Claude Code** installed and authenticated (`claude --version`)
11
+ - A C/C++ toolchain — `node-pty` builds from source on install (`python3`, `make`, a working C++ compiler)
12
+ - For the glasses half: Even Realities G2 + the Even Realities app on Android with the companion `.ehpk` plugin sideloaded
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install -g @xerktech/claude-hud
18
+ ```
19
+
20
+ That puts the `claude-hud` binary on your `$PATH`. The package is published to the public npm registry with [provenance](https://docs.npmjs.com/generating-provenance-statements) — you can verify the source commit it was built from on the [npm page](https://www.npmjs.com/package/@xerktech/claude-hud).
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ claude-hud # ensure broker + register cwd + run claude
26
+ claude-hud pair # re-print the broker's pairing QR
27
+ claude-hud broker status # is the broker up? sessions? paired?
28
+ claude-hud broker logs # tail the detached broker log
29
+ claude-hud broker stop # terminate the detached broker
30
+ claude-hud --help # full usage
31
+ ```
32
+
33
+ Anything after `claude-hud` that isn't a subcommand is forwarded to `claude` verbatim — including `--resume <id>`, model selectors, and the like.
34
+
35
+ First run prints a QR code; scan it from the companion plugin on your phone to pair the glasses with the broker. Tokens, settings, and the generated TLS cert live under `~/.claude-hud/` (override with `CLAUDE_HUD_DIR`).
36
+
37
+ ## How it fits together
38
+
39
+ ```
40
+ +--- Glasses (G2) ---+ BLE +--- Android phone ---+ HTTPS/WS +--- This CLI ---+
41
+ | 576×288 greyscale | <---> | Even App + plugin | <--------> | broker + PTY |
42
+ | Touchbars, mics | | (.ehpk we publish) | LAN | spawns claude |
43
+ +--------------------+ +---------------------+ +----------------+
44
+ ```
45
+
46
+ Voice tokens, transcripts, and approvals flow over your LAN only — no Tailscale, no Cloudflare Tunnel, no telemetry.
47
+
48
+ ## License
49
+
50
+ MIT — see [LICENSE](https://github.com/xerktech/claudehud/blob/main/LICENSE).
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Tracks the live `inject` WebSockets the `claude-hud` wrapper opens for each
3
+ * attached session. When the user dictates a prompt from the glasses, the
4
+ * SessionManager routes it through here instead of writing to a pty — the
5
+ * wrapper receives the text, types it into the running `claude` TUI, and the
6
+ * normal Claude Code flow takes over.
7
+ *
8
+ * Each session has at most one connected wrapper. If a second wrapper appears
9
+ * for the same id (broker rebound, race during reconnect), it replaces the
10
+ * old one and the old socket gets closed.
11
+ */
12
+ export class AttachedSessionHub {
13
+ connections = new Map();
14
+ register(sessionId, ws) {
15
+ const existing = this.connections.get(sessionId);
16
+ if (existing && existing !== ws) {
17
+ try {
18
+ existing.close(1000, 'replaced');
19
+ }
20
+ catch {
21
+ // ignore — already gone
22
+ }
23
+ }
24
+ this.connections.set(sessionId, ws);
25
+ ws.on('close', () => {
26
+ // Only clear if it's still this socket; a newer one may have replaced it.
27
+ if (this.connections.get(sessionId) === ws) {
28
+ this.connections.delete(sessionId);
29
+ }
30
+ });
31
+ }
32
+ unregister(sessionId) {
33
+ const ws = this.connections.get(sessionId);
34
+ if (!ws)
35
+ return;
36
+ this.connections.delete(sessionId);
37
+ try {
38
+ ws.close(1000, 'detached');
39
+ }
40
+ catch {
41
+ // ignore
42
+ }
43
+ }
44
+ has(sessionId) {
45
+ return this.connections.has(sessionId);
46
+ }
47
+ /**
48
+ * Send a dictated prompt to the wrapper. Returns true on best-effort send.
49
+ * Returns false if there's no live wrapper for this session (the broker
50
+ * holds the input but can't deliver it — the caller should surface a toast).
51
+ */
52
+ send(sessionId, text) {
53
+ const ws = this.connections.get(sessionId);
54
+ if (!ws)
55
+ return false;
56
+ if (ws.readyState !== ws.OPEN)
57
+ return false;
58
+ try {
59
+ ws.send(JSON.stringify({ type: 'inject', text }));
60
+ return true;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ closeAll(reason = 'broker_shutdown') {
67
+ for (const ws of this.connections.values()) {
68
+ try {
69
+ ws.close(1000, reason);
70
+ }
71
+ catch {
72
+ // ignore
73
+ }
74
+ }
75
+ this.connections.clear();
76
+ }
77
+ }
78
+ //# sourceMappingURL=attached.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attached.js","sourceRoot":"","sources":["../src/attached.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,MAAM,OAAO,kBAAkB;IACrB,WAAW,GAAG,IAAI,GAAG,EAAqB,CAAA;IAElD,QAAQ,CAAC,SAAiB,EAAE,EAAa;QACvC,MAAM,QAAQ,GAAG,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;QAChD,IAAI,QAAQ,IAAI,QAAQ,KAAK,EAAE,EAAE,CAAC;YAChC,IAAI,CAAC;gBACH,QAAQ,CAAC,KAAK,CAAC,IAAI,EAAE,UAAU,CAAC,CAAA;YAClC,CAAC;YAAC,MAAM,CAAC;gBACP,wBAAwB;YAC1B,CAAC;QACH,CAAC;QACD,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,SAAS,EAAE,EAAE,CAAC,CAAA;QACnC,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YAClB,0EAA0E;YAC1E,IAAI,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,SAAS,CAAC,KAAK,EAAE,EAAE,CAAC;gBAC3C,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;YACpC,CAAC;QACH,CAAC,CAAC,CAAA;IACJ,CAAC;IAED,UAAU,CAAC,SAAiB;QAC1B,MAAM,EAAE,GAAG,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;QAC1C,IAAI,CAAC,EAAE;YAAE,OAAM;QACf,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;QAClC,IAAI,CAAC;YACH,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,UAAU,CAAC,CAAA;QAC5B,CAAC;QAAC,MAAM,CAAC;YACP,SAAS;QACX,CAAC;IACH,CAAC;IAED,GAAG,CAAC,SAAiB;QACnB,OAAO,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;IACxC,CAAC;IAED;;;;OAIG;IACH,IAAI,CAAC,SAAiB,EAAE,IAAY;QAClC,MAAM,EAAE,GAAG,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;QAC1C,IAAI,CAAC,EAAE;YAAE,OAAO,KAAK,CAAA;QACrB,IAAI,EAAE,CAAC,UAAU,KAAK,EAAE,CAAC,IAAI;YAAE,OAAO,KAAK,CAAA;QAC3C,IAAI,CAAC;YACH,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;YACjD,OAAO,IAAI,CAAA;QACb,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,KAAK,CAAA;QACd,CAAC;IACH,CAAC;IAED,QAAQ,CAAC,MAAM,GAAG,iBAAiB;QACjC,KAAK,MAAM,EAAE,IAAI,IAAI,CAAC,WAAW,CAAC,MAAM,EAAE,EAAE,CAAC;YAC3C,IAAI,CAAC;gBACH,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;YACxB,CAAC;YAAC,MAAM,CAAC;gBACP,SAAS;YACX,CAAC;QACH,CAAC;QACD,IAAI,CAAC,WAAW,CAAC,KAAK,EAAE,CAAA;IAC1B,CAAC;CACF"}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * PCM ↔ WAV helpers for the voice pipeline.
3
+ *
4
+ * The phone uploads raw 16 kHz, signed-16-bit-little-endian, mono PCM frames
5
+ * over the audio WebSocket. whisper.cpp's HTTP server expects a complete WAV
6
+ * file, so we wrap the buffered PCM in a 44-byte RIFF header before posting.
7
+ *
8
+ * Format constants pinned to the G2 hardware spec (device-features skill,
9
+ * CLAUDE.md §4 "Audio").
10
+ */
11
+ export const SAMPLE_RATE_HZ = 16_000;
12
+ export const BITS_PER_SAMPLE = 16;
13
+ export const NUM_CHANNELS = 1;
14
+ const BYTES_PER_SAMPLE = BITS_PER_SAMPLE / 8;
15
+ const BYTE_RATE = SAMPLE_RATE_HZ * NUM_CHANNELS * BYTES_PER_SAMPLE;
16
+ const BLOCK_ALIGN = NUM_CHANNELS * BYTES_PER_SAMPLE;
17
+ /**
18
+ * Buffers binary PCM frames as they arrive and concatenates them on demand.
19
+ * Caps total buffered bytes to guard against a wedged phone that never closes
20
+ * the WS — capExceeded() lets the caller bail and force-close the connection.
21
+ */
22
+ export class PcmAggregator {
23
+ maxBytes;
24
+ chunks = [];
25
+ totalBytes = 0;
26
+ constructor(maxBytes = 60 * BYTE_RATE) {
27
+ this.maxBytes = maxBytes;
28
+ // Default cap: 60 seconds of audio. Enough for any one utterance.
29
+ }
30
+ push(chunk) {
31
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
32
+ if (buf.length === 0)
33
+ return;
34
+ this.chunks.push(buf);
35
+ this.totalBytes += buf.length;
36
+ }
37
+ /** True when the aggregator has accepted more PCM than `maxBytes`. */
38
+ capExceeded() {
39
+ return this.totalBytes > this.maxBytes;
40
+ }
41
+ bytes() {
42
+ return this.totalBytes;
43
+ }
44
+ /** Approximate captured audio duration in milliseconds. */
45
+ durationMs() {
46
+ const samples = Math.floor(this.totalBytes / BYTES_PER_SAMPLE);
47
+ return Math.round((samples / SAMPLE_RATE_HZ) * 1000);
48
+ }
49
+ /** Concatenated raw PCM. Empty buffer if nothing was pushed. */
50
+ concat() {
51
+ if (this.chunks.length === 0)
52
+ return Buffer.alloc(0);
53
+ return Buffer.concat(this.chunks, this.totalBytes);
54
+ }
55
+ reset() {
56
+ this.chunks = [];
57
+ this.totalBytes = 0;
58
+ }
59
+ }
60
+ /**
61
+ * Wrap a raw PCM buffer (16 kHz, s16le, mono) in a canonical WAV/RIFF header
62
+ * so it can be POSTed to a whisper.cpp HTTP server (which only accepts WAV).
63
+ */
64
+ export function pcmToWav(pcm) {
65
+ const dataSize = pcm.length;
66
+ const header = Buffer.alloc(44);
67
+ header.write('RIFF', 0, 'ascii');
68
+ header.writeUInt32LE(36 + dataSize, 4);
69
+ header.write('WAVE', 8, 'ascii');
70
+ header.write('fmt ', 12, 'ascii');
71
+ header.writeUInt32LE(16, 16); // fmt chunk size
72
+ header.writeUInt16LE(1, 20); // audio format: PCM
73
+ header.writeUInt16LE(NUM_CHANNELS, 22);
74
+ header.writeUInt32LE(SAMPLE_RATE_HZ, 24);
75
+ header.writeUInt32LE(BYTE_RATE, 28);
76
+ header.writeUInt16LE(BLOCK_ALIGN, 32);
77
+ header.writeUInt16LE(BITS_PER_SAMPLE, 34);
78
+ header.write('data', 36, 'ascii');
79
+ header.writeUInt32LE(dataSize, 40);
80
+ return Buffer.concat([header, pcm], header.length + dataSize);
81
+ }
82
+ //# sourceMappingURL=audio-codec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio-codec.js","sourceRoot":"","sources":["../src/audio-codec.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Audio session — one per active WS connection. Buffers PCM frames from the
3
+ * phone, finalises on `close()`, runs STT, parses the resulting transcript
4
+ * into an intent, and hands the intent off to a dispatcher.
5
+ *
6
+ * Kept deliberately small: the WS plumbing lives in `server.ts`, and the
7
+ * intent-to-action mapping lives in `intent-dispatcher.ts`. This module is the
8
+ * narrow waist where audio bytes become user intent.
9
+ */
10
+ import { PcmAggregator } from "./audio-codec.js";
11
+ import { parseIntent } from "./intent.js";
12
+ export class AudioSession {
13
+ sessionId;
14
+ deps;
15
+ aggregator;
16
+ closed = false;
17
+ constructor(sessionId, deps) {
18
+ this.sessionId = sessionId;
19
+ this.deps = deps;
20
+ this.aggregator = new PcmAggregator(deps.maxBytes);
21
+ }
22
+ /** Push a binary PCM frame from the WS. Silently drops once over the cap. */
23
+ pushFrame(chunk) {
24
+ if (this.closed)
25
+ return;
26
+ if (this.aggregator.capExceeded())
27
+ return;
28
+ this.aggregator.push(chunk);
29
+ }
30
+ bytes() {
31
+ return this.aggregator.bytes();
32
+ }
33
+ /** True when the cumulative buffered audio has exceeded the configured cap. */
34
+ capExceeded() {
35
+ return this.aggregator.capExceeded();
36
+ }
37
+ /**
38
+ * Finalise the recording. Calls STT, parses the transcript into an intent,
39
+ * and notifies the dispatcher via `onResult`. Safe to call multiple times —
40
+ * subsequent calls are no-ops.
41
+ */
42
+ async close() {
43
+ if (this.closed)
44
+ return null;
45
+ this.closed = true;
46
+ const bytes = this.aggregator.bytes();
47
+ const durationMs = this.aggregator.durationMs();
48
+ const pcm = this.aggregator.concat();
49
+ this.aggregator.reset();
50
+ let transcript = { text: '', unavailable: true, reason: 'no audio received' };
51
+ if (pcm.length > 0) {
52
+ try {
53
+ transcript = await this.deps.stt.transcribe(pcm);
54
+ }
55
+ catch (err) {
56
+ const reason = err.message;
57
+ transcript = { text: '', unavailable: true, reason };
58
+ this.deps.onError?.(err);
59
+ }
60
+ }
61
+ const projects = this.deps.listProjects();
62
+ const intent = transcript.text
63
+ ? parseIntent(transcript.text, projects)
64
+ : { kind: 'noop', reason: transcript.reason ?? 'no transcript' };
65
+ const result = {
66
+ sessionId: this.sessionId,
67
+ transcript,
68
+ intent,
69
+ durationMs,
70
+ bytes,
71
+ };
72
+ try {
73
+ this.deps.onResult(result);
74
+ }
75
+ catch (err) {
76
+ this.deps.onError?.(err);
77
+ }
78
+ return result;
79
+ }
80
+ }
81
+ //# sourceMappingURL=audio-session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio-session.js","sourceRoot":"","sources":["../src/audio-session.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAEhD,OAAO,EAAE,WAAW,EAAe,MAAM,aAAa,CAAA;AAsBtD,MAAM,OAAO,YAAY;IAKZ;IACQ;IALF,UAAU,CAAe;IAClC,MAAM,GAAG,KAAK,CAAA;IAEtB,YACW,SAAiB,EACT,IAAsB;QAD9B,cAAS,GAAT,SAAS,CAAQ;QACT,SAAI,GAAJ,IAAI,CAAkB;QAEvC,IAAI,CAAC,UAAU,GAAG,IAAI,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IACpD,CAAC;IAED,6EAA6E;IAC7E,SAAS,CAAC,KAA0B;QAClC,IAAI,IAAI,CAAC,MAAM;YAAE,OAAM;QACvB,IAAI,IAAI,CAAC,UAAU,CAAC,WAAW,EAAE;YAAE,OAAM;QACzC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAC7B,CAAC;IAED,KAAK;QACH,OAAO,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,CAAA;IAChC,CAAC;IAED,+EAA+E;IAC/E,WAAW;QACT,OAAO,IAAI,CAAC,UAAU,CAAC,WAAW,EAAE,CAAA;IACtC,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,KAAK;QACT,IAAI,IAAI,CAAC,MAAM;YAAE,OAAO,IAAI,CAAA;QAC5B,IAAI,CAAC,MAAM,GAAG,IAAI,CAAA;QAClB,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,CAAA;QACrC,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,CAAC,UAAU,EAAE,CAAA;QAC/C,MAAM,GAAG,GAAG,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAA;QACpC,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,CAAA;QAEvB,IAAI,UAAU,GAAc,EAAE,IAAI,EAAE,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,mBAAmB,EAAE,CAAA;QACxF,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACnB,IAAI,CAAC;gBACH,UAAU,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,CAAA;YAClD,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,MAAM,MAAM,GAAI,GAAa,CAAC,OAAO,CAAA;gBACrC,UAAU,GAAG,EAAE,IAAI,EAAE,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,CAAA;gBACpD,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,GAAY,CAAC,CAAA;YACnC,CAAC;QACH,CAAC;QAED,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,CAAA;QACzC,MAAM,MAAM,GAAW,UAAU,CAAC,IAAI;YACpC,CAAC,CAAC,WAAW,CAAC,UAAU,CAAC,IAAI,EAAE,QAAQ,CAAC;YACxC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,IAAI,eAAe,EAAE,CAAA;QAElE,MAAM,MAAM,GAAuB;YACjC,SAAS,EAAE,IAAI,CAAC,SAAS;YACzB,UAAU;YACV,MAAM;YACN,UAAU;YACV,KAAK;SACN,CAAA;QACD,IAAI,CAAC;YACH,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;QAC5B,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,GAAY,CAAC,CAAA;QACnC,CAAC;QACD,OAAO,MAAM,CAAA;IACf,CAAC;CACF"}
@@ -0,0 +1,32 @@
1
+ import crypto from 'node:crypto';
2
+ const PUBLIC_PATHS = new Set(['/api/health']);
3
+ export function createBearerAuth(token) {
4
+ if (!token || token.length < 32) {
5
+ throw new Error('bearer token must be at least 32 chars');
6
+ }
7
+ return function bearerAuth(req, res, next) {
8
+ if (req.method === 'OPTIONS' || PUBLIC_PATHS.has(req.path)) {
9
+ next();
10
+ return;
11
+ }
12
+ const header = req.headers.authorization;
13
+ if (!header || !header.startsWith('Bearer ')) {
14
+ res.status(401).json({ error: 'unauthorized' });
15
+ return;
16
+ }
17
+ const presented = header.slice(7).trim();
18
+ if (!constantTimeEqual(presented, token)) {
19
+ res.status(401).json({ error: 'unauthorized' });
20
+ return;
21
+ }
22
+ next();
23
+ };
24
+ }
25
+ export function constantTimeEqual(a, b) {
26
+ const bufA = Buffer.from(a);
27
+ const bufB = Buffer.from(b);
28
+ if (bufA.length !== bufB.length)
29
+ return false;
30
+ return crypto.timingSafeEqual(bufA, bufB);
31
+ }
32
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,aAAa,CAAA;AAGhC,MAAM,YAAY,GAAG,IAAI,GAAG,CAAS,CAAC,aAAa,CAAC,CAAC,CAAA;AAErD,MAAM,UAAU,gBAAgB,CAAC,KAAa;IAC5C,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,EAAE,EAAE,CAAC;QAChC,MAAM,IAAI,KAAK,CAAC,wCAAwC,CAAC,CAAA;IAC3D,CAAC;IACD,OAAO,SAAS,UAAU,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;QACxE,IAAI,GAAG,CAAC,MAAM,KAAK,SAAS,IAAI,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;YAC3D,IAAI,EAAE,CAAA;YACN,OAAM;QACR,CAAC;QACD,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAA;QACxC,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;YAC7C,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAA;YAC/C,OAAM;QACR,CAAC;QACD,MAAM,SAAS,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QACxC,IAAI,CAAC,iBAAiB,CAAC,SAAS,EAAE,KAAK,CAAC,EAAE,CAAC;YACzC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAA;YAC/C,OAAM;QACR,CAAC;QACD,IAAI,EAAE,CAAA;IACR,CAAC,CAAA;AACH,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,CAAS,EAAE,CAAS;IACpD,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAC3B,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAC3B,IAAI,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM;QAAE,OAAO,KAAK,CAAA;IAC7C,OAAO,MAAM,CAAC,eAAe,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;AAC3C,CAAC"}
@@ -0,0 +1,76 @@
1
+ const RING_SIZE = 1000;
2
+ class Channel {
3
+ name;
4
+ events = [];
5
+ nextId = 1;
6
+ clients = new Set();
7
+ constructor(name) {
8
+ this.name = name;
9
+ }
10
+ publish(event, data) {
11
+ const e = { id: this.nextId++, channel: this.name, event, data };
12
+ this.events.push(e);
13
+ if (this.events.length > RING_SIZE) {
14
+ this.events.splice(0, this.events.length - RING_SIZE);
15
+ }
16
+ for (const client of this.clients) {
17
+ writeEvent(client, e);
18
+ }
19
+ return e;
20
+ }
21
+ subscribe(res, lastEventId) {
22
+ if (typeof lastEventId === 'number' && Number.isFinite(lastEventId)) {
23
+ for (const e of this.events) {
24
+ if (e.id > lastEventId)
25
+ writeEvent(res, e);
26
+ }
27
+ }
28
+ this.clients.add(res);
29
+ res.on('close', () => this.clients.delete(res));
30
+ }
31
+ bufferedCount() {
32
+ return this.events.length;
33
+ }
34
+ clientCount() {
35
+ return this.clients.size;
36
+ }
37
+ closeClients() {
38
+ for (const client of this.clients) {
39
+ try {
40
+ client.end();
41
+ }
42
+ catch {
43
+ // ignore
44
+ }
45
+ }
46
+ this.clients.clear();
47
+ }
48
+ }
49
+ export class EventBus {
50
+ channels = new Map();
51
+ ensure(name) {
52
+ let c = this.channels.get(name);
53
+ if (!c) {
54
+ c = new Channel(name);
55
+ this.channels.set(name, c);
56
+ }
57
+ return c;
58
+ }
59
+ publish(channel, event, data) {
60
+ return this.ensure(channel).publish(event, data);
61
+ }
62
+ subscribe(channel, res, lastEventId) {
63
+ this.ensure(channel).subscribe(res, lastEventId);
64
+ }
65
+ closeAll() {
66
+ for (const channel of this.channels.values()) {
67
+ channel.closeClients();
68
+ }
69
+ }
70
+ }
71
+ function writeEvent(res, e) {
72
+ res.write(`id: ${e.id}\n`);
73
+ res.write(`event: ${e.event}\n`);
74
+ res.write(`data: ${JSON.stringify(e.data)}\n\n`);
75
+ }
76
+ //# sourceMappingURL=bus.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bus.js","sourceRoot":"","sources":["../src/bus.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import selfsigned from 'selfsigned';
5
+ export function ensureCert(certDir, hostname) {
6
+ fs.mkdirSync(certDir, { recursive: true });
7
+ const certPath = path.join(certDir, 'broker.crt');
8
+ const keyPath = path.join(certDir, 'broker.key');
9
+ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
10
+ return {
11
+ cert: fs.readFileSync(certPath, 'utf8'),
12
+ key: fs.readFileSync(keyPath, 'utf8'),
13
+ source: 'reused',
14
+ certPath,
15
+ keyPath,
16
+ };
17
+ }
18
+ if (hasMkcert()) {
19
+ try {
20
+ execFileSync('mkcert', ['-cert-file', certPath, '-key-file', keyPath, hostname, '127.0.0.1', 'localhost'], { stdio: 'pipe' });
21
+ fs.chmodSync(keyPath, 0o600);
22
+ return {
23
+ cert: fs.readFileSync(certPath, 'utf8'),
24
+ key: fs.readFileSync(keyPath, 'utf8'),
25
+ source: 'mkcert',
26
+ certPath,
27
+ keyPath,
28
+ };
29
+ }
30
+ catch (err) {
31
+ console.warn(`[cert] mkcert failed, falling back to selfsigned: ${err.message}`);
32
+ }
33
+ }
34
+ const attrs = [{ name: 'commonName', value: hostname }];
35
+ const pems = selfsigned.generate(attrs, {
36
+ days: 825,
37
+ keySize: 2048,
38
+ extensions: [
39
+ { name: 'basicConstraints', cA: false },
40
+ {
41
+ name: 'keyUsage',
42
+ digitalSignature: true,
43
+ keyEncipherment: true,
44
+ },
45
+ {
46
+ name: 'extKeyUsage',
47
+ serverAuth: true,
48
+ },
49
+ {
50
+ name: 'subjectAltName',
51
+ altNames: [
52
+ { type: 2, value: hostname },
53
+ { type: 2, value: 'localhost' },
54
+ { type: 7, ip: '127.0.0.1' },
55
+ ],
56
+ },
57
+ ],
58
+ });
59
+ fs.writeFileSync(certPath, pems.cert);
60
+ fs.writeFileSync(keyPath, pems.private, { mode: 0o600 });
61
+ return { cert: pems.cert, key: pems.private, source: 'selfsigned', certPath, keyPath };
62
+ }
63
+ function hasMkcert() {
64
+ try {
65
+ execFileSync('mkcert', ['-help'], { stdio: 'pipe' });
66
+ return true;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ //# sourceMappingURL=cert.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cert.js","sourceRoot":"","sources":["../src/cert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAA;AACxB,OAAO,IAAI,MAAM,WAAW,CAAA;AAC5B,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,UAAU,MAAM,YAAY,CAAA;AAYnC,MAAM,UAAU,UAAU,CAAC,OAAe,EAAE,QAAgB;IAC1D,EAAE,CAAC,SAAS,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAC1C,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,YAAY,CAAC,CAAA;IACjD,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,YAAY,CAAC,CAAA;IAEhD,IAAI,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;QACtD,OAAO;YACL,IAAI,EAAE,EAAE,CAAC,YAAY,CAAC,QAAQ,EAAE,MAAM,CAAC;YACvC,GAAG,EAAE,EAAE,CAAC,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC;YACrC,MAAM,EAAE,QAAQ;YAChB,QAAQ;YACR,OAAO;SACR,CAAA;IACH,CAAC;IAED,IAAI,SAAS,EAAE,EAAE,CAAC;QAChB,IAAI,CAAC;YACH,YAAY,CACV,QAAQ,EACR,CAAC,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,WAAW,CAAC,EAClF,EAAE,KAAK,EAAE,MAAM,EAAE,CAClB,CAAA;YACD,EAAE,CAAC,SAAS,CAAC,OAAO,EAAE,KAAK,CAAC,CAAA;YAC5B,OAAO;gBACL,IAAI,EAAE,EAAE,CAAC,YAAY,CAAC,QAAQ,EAAE,MAAM,CAAC;gBACvC,GAAG,EAAE,EAAE,CAAC,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC;gBACrC,MAAM,EAAE,QAAQ;gBAChB,QAAQ;gBACR,OAAO;aACR,CAAA;QACH,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,CAAC,IAAI,CAAC,qDAAsD,GAAa,CAAC,OAAO,EAAE,CAAC,CAAA;QAC7F,CAAC;IACH,CAAC;IAED,MAAM,KAAK,GAAG,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAA;IACvD,MAAM,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,KAAK,EAAE;QACtC,IAAI,EAAE,GAAG;QACT,OAAO,EAAE,IAAI;QACb,UAAU,EAAE;YACV,EAAE,IAAI,EAAE,kBAAkB,EAAE,EAAE,EAAE,KAAK,EAAE;YACvC;gBACE,IAAI,EAAE,UAAU;gBAChB,gBAAgB,EAAE,IAAI;gBACtB,eAAe,EAAE,IAAI;aACtB;YACD;gBACE,IAAI,EAAE,aAAa;gBACnB,UAAU,EAAE,IAAI;aACjB;YACD;gBACE,IAAI,EAAE,gBAAgB;gBACtB,QAAQ,EAAE;oBACR,EAAE,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE;oBAC5B,EAAE,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE;oBAC/B,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,EAAE,WAAW,EAAE;iBAC7B;aACF;SACF;KACF,CAAC,CAAA;IACF,EAAE,CAAC,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;IACrC,EAAE,CAAC,aAAa,CAAC,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAA;IACxD,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,EAAE,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAA;AACxF,CAAC;AAED,SAAS,SAAS;IAChB,IAAI,CAAC;QACH,YAAY,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAA;QACpD,OAAO,IAAI,CAAA;IACb,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC"}
@@ -0,0 +1,160 @@
1
+ import { spawn as cpSpawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ export function parseStreamJsonLine(line) {
6
+ const trimmed = line.trim();
7
+ if (!trimmed)
8
+ return null;
9
+ try {
10
+ const parsed = JSON.parse(trimmed);
11
+ if (typeof parsed === 'object' && parsed !== null && typeof parsed.type === 'string') {
12
+ return parsed;
13
+ }
14
+ return null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export function extractAssistantText(ev) {
21
+ if (ev.type !== 'assistant')
22
+ return null;
23
+ const blocks = ev.message?.content;
24
+ if (!Array.isArray(blocks))
25
+ return null;
26
+ const text = blocks
27
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
28
+ .map(b => b.text)
29
+ .join('');
30
+ return text.length > 0 ? text : null;
31
+ }
32
+ export function encodeUserTurn(text) {
33
+ const payload = { type: 'user', message: { role: 'user', content: text } };
34
+ return `${JSON.stringify(payload)}\n`;
35
+ }
36
+ export class LineSplitter {
37
+ buffer = '';
38
+ push(chunk) {
39
+ this.buffer += chunk;
40
+ const lines = [];
41
+ let idx;
42
+ while ((idx = this.buffer.indexOf('\n')) >= 0) {
43
+ const line = this.buffer.slice(0, idx);
44
+ this.buffer = this.buffer.slice(idx + 1);
45
+ if (line.length > 0)
46
+ lines.push(line);
47
+ }
48
+ return lines;
49
+ }
50
+ flush() {
51
+ const rest = this.buffer;
52
+ this.buffer = '';
53
+ return rest.length > 0 ? [rest] : [];
54
+ }
55
+ }
56
+ /**
57
+ * Build the CLI argv for spawning `claude`. Exported separately so callers /
58
+ * tests can assert exact flag wiring — most importantly that we NEVER pass
59
+ * `--dangerously-skip-permissions`. CLAUDE.md §12 + M7 plan both require this:
60
+ * every tool prompt must route through the broker's hook gate, so we always
61
+ * spawn with default permission enforcement.
62
+ *
63
+ * `--print` is mandatory: the stream-json codec is the **headless** transport.
64
+ * Without `--print` the CLI runs an interactive REPL and never consumes
65
+ * stream-json from stdin. The pty stays alive as long as we keep stdin open;
66
+ * each line is one user turn.
67
+ */
68
+ export function buildClaudeArgs(opts) {
69
+ const args = [
70
+ '--print',
71
+ '--output-format',
72
+ 'stream-json',
73
+ '--input-format',
74
+ 'stream-json',
75
+ '--verbose',
76
+ ];
77
+ if (opts.resumeId) {
78
+ args.push('--resume', opts.resumeId);
79
+ }
80
+ if (opts.model) {
81
+ args.push('--model', opts.model);
82
+ }
83
+ return args;
84
+ }
85
+ export const defaultSpawnClaude = opts => {
86
+ const args = buildClaudeArgs(opts);
87
+ const bin = resolveClaudeBin();
88
+ // Plain stdio pipes — not a PTY. `claude --print` reads stdin only when it's
89
+ // detected as piped (not a TTY); a PTY would (a) make stdin look like a TTY
90
+ // and crash the spawn, and (b) on Windows ConPTY would wrap stdout at the
91
+ // configured column width with cursor-position escapes, garbling stream-json
92
+ // lines past ~200 chars. Headless mode doesn't need a TTY anyway.
93
+ const child = cpSpawn(bin, args, {
94
+ cwd: opts.cwd,
95
+ env: { ...process.env, ...opts.env },
96
+ stdio: ['pipe', 'pipe', 'pipe'],
97
+ windowsHide: true,
98
+ });
99
+ return wrapChild(child);
100
+ };
101
+ /**
102
+ * Locate the claude executable. On Windows, `child_process.spawn` without
103
+ * `shell: true` doesn't honour PATHEXT, so a bare `claude` would not resolve
104
+ * to `claude.exe`. We walk PATH ourselves; on other platforms we just trust
105
+ * PATH lookup to find a `claude` binary on it.
106
+ */
107
+ function resolveClaudeBin() {
108
+ if (process.platform !== 'win32')
109
+ return 'claude';
110
+ const exts = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean);
111
+ const paths = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
112
+ for (const dir of paths) {
113
+ for (const ext of exts) {
114
+ const candidate = path.join(dir, `claude${ext}`);
115
+ if (fs.existsSync(candidate))
116
+ return candidate;
117
+ }
118
+ }
119
+ // Last-ditch: let spawn fail with a clear error if we're really missing claude.
120
+ return 'claude.exe';
121
+ }
122
+ function wrapChild(cp) {
123
+ return {
124
+ write: data => {
125
+ cp.stdin?.write(data);
126
+ },
127
+ kill: signal => {
128
+ try {
129
+ cp.kill(signal ?? 'SIGTERM');
130
+ }
131
+ catch {
132
+ // process already gone — onExit has fired (or is about to).
133
+ }
134
+ },
135
+ onData: cb => {
136
+ // Merge stdout + stderr into one stream — the broker's LineSplitter
137
+ // tolerates non-JSON lines (drops them) and the user-visible event flow
138
+ // only cares about stream-json events on stdout. Pulling stderr in too
139
+ // makes diagnostics like "Error: API key invalid" reachable from the bus.
140
+ const onStdout = (d) => cb(d.toString());
141
+ const onStderr = (d) => cb(d.toString());
142
+ cp.stdout?.on('data', onStdout);
143
+ cp.stderr?.on('data', onStderr);
144
+ return () => {
145
+ cp.stdout?.off('data', onStdout);
146
+ cp.stderr?.off('data', onStderr);
147
+ };
148
+ },
149
+ onExit: cb => {
150
+ const handler = (code, signal) => {
151
+ cb({ exitCode: code ?? 0, signal: signal ? -1 : undefined });
152
+ };
153
+ cp.on('exit', handler);
154
+ return () => {
155
+ cp.off('exit', handler);
156
+ };
157
+ },
158
+ };
159
+ }
160
+ //# sourceMappingURL=claude.js.map