@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.
- package/README.md +50 -0
- package/dist/_broker/attached.js +78 -0
- package/dist/_broker/attached.js.map +1 -0
- package/dist/_broker/audio-codec.js +82 -0
- package/dist/_broker/audio-codec.js.map +1 -0
- package/dist/_broker/audio-session.js +81 -0
- package/dist/_broker/audio-session.js.map +1 -0
- package/dist/_broker/auth.js +32 -0
- package/dist/_broker/auth.js.map +1 -0
- package/dist/_broker/bus.js +76 -0
- package/dist/_broker/bus.js.map +1 -0
- package/dist/_broker/cert.js +72 -0
- package/dist/_broker/cert.js.map +1 -0
- package/dist/_broker/claude.js +160 -0
- package/dist/_broker/claude.js.map +1 -0
- package/dist/_broker/cli.js +134 -0
- package/dist/_broker/cli.js.map +1 -0
- package/dist/_broker/cors.js +48 -0
- package/dist/_broker/cors.js.map +1 -0
- package/dist/_broker/hooks.js +196 -0
- package/dist/_broker/hooks.js.map +1 -0
- package/dist/_broker/index.js +48 -0
- package/dist/_broker/index.js.map +1 -0
- package/dist/_broker/intent-dispatcher.js +86 -0
- package/dist/_broker/intent-dispatcher.js.map +1 -0
- package/dist/_broker/intent.js +127 -0
- package/dist/_broker/intent.js.map +1 -0
- package/dist/_broker/jsonl-tail.js +185 -0
- package/dist/_broker/jsonl-tail.js.map +1 -0
- package/dist/_broker/mdns.js +41 -0
- package/dist/_broker/mdns.js.map +1 -0
- package/dist/_broker/projects.js +161 -0
- package/dist/_broker/projects.js.map +1 -0
- package/dist/_broker/qr.js +11 -0
- package/dist/_broker/qr.js.map +1 -0
- package/dist/_broker/routes.js +379 -0
- package/dist/_broker/routes.js.map +1 -0
- package/dist/_broker/server.js +325 -0
- package/dist/_broker/server.js.map +1 -0
- package/dist/_broker/sessions-store.js +50 -0
- package/dist/_broker/sessions-store.js.map +1 -0
- package/dist/_broker/sessions.js +792 -0
- package/dist/_broker/sessions.js.map +1 -0
- package/dist/_broker/store.js +79 -0
- package/dist/_broker/store.js.map +1 -0
- package/dist/_broker/stt.js +93 -0
- package/dist/_broker/stt.js.map +1 -0
- package/dist/broker-bin.js +37 -0
- package/dist/broker-bin.js.map +1 -0
- package/dist/broker-lifecycle.js +186 -0
- package/dist/broker-lifecycle.js.map +1 -0
- package/dist/index.js +189 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.js +17 -0
- package/dist/paths.js.map +1 -0
- package/dist/wrapper/index.js +263 -0
- package/dist/wrapper/index.js.map +1 -0
- package/dist/wrapper/slug.js +5 -0
- package/dist/wrapper/slug.js.map +1 -0
- 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":"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"}
|
|
@@ -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
|