hermes-to-claude 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.
@@ -0,0 +1,1677 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/hbridge/server.mjs
4
+ import { createServer as http } from "http";
5
+
6
+ // src/hbridge/bridge.mjs
7
+ import { randomUUID } from "crypto";
8
+
9
+ // src/hbridge/session.mjs
10
+ import { spawn } from "child_process";
11
+
12
+ // src/hbridge/transport/StdioTransport.mjs
13
+ import { createInterface } from "readline";
14
+ import { createWriteStream } from "fs";
15
+ import { join } from "path";
16
+ import { homedir } from "os";
17
+
18
+ // src/hbridge/transport/SerialBatchEventUploader.mjs
19
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+ var RetryableError = class extends Error {
21
+ /**
22
+ * @param {string} message
23
+ * @param {number} [retryAfterMs]
24
+ */
25
+ constructor(message, retryAfterMs) {
26
+ super(message);
27
+ this.name = "RetryableError";
28
+ this.retryAfterMs = retryAfterMs;
29
+ }
30
+ };
31
+ var SerialBatchEventUploader = class {
32
+ /**
33
+ * @param {{
34
+ * maxBatchSize: number
35
+ * maxBatchBytes?: number
36
+ * maxQueueSize: number
37
+ * send: (batch: unknown[]) => Promise<void>
38
+ * baseDelayMs: number
39
+ * maxDelayMs: number
40
+ * jitterMs: number
41
+ * maxConsecutiveFailures?: number
42
+ * onBatchDropped?: (batchSize: number, failures: number) => void
43
+ * }} config
44
+ */
45
+ constructor(config) {
46
+ this.pending = [];
47
+ this.pendingAtClose = 0;
48
+ this.draining = false;
49
+ this.closed = false;
50
+ this.backpressureResolvers = [];
51
+ this.flushResolvers = [];
52
+ this.droppedBatches = 0;
53
+ this._drainPromise = null;
54
+ this.config = {
55
+ maxBatchSize: config.maxBatchSize,
56
+ maxBatchBytes: config.maxBatchBytes,
57
+ maxQueueSize: config.maxQueueSize,
58
+ send: config.send,
59
+ baseDelayMs: config.baseDelayMs,
60
+ maxDelayMs: config.maxDelayMs,
61
+ jitterMs: config.jitterMs,
62
+ maxConsecutiveFailures: config.maxConsecutiveFailures,
63
+ onBatchDropped: config.onBatchDropped ?? (() => {
64
+ })
65
+ };
66
+ }
67
+ /** Monotonic count of batches dropped via maxConsecutiveFailures. */
68
+ get droppedBatchCount() {
69
+ return this.droppedBatches;
70
+ }
71
+ /** Pending queue depth. After close(), returns count at close time. */
72
+ get pendingCount() {
73
+ return this.closed ? this.pendingAtClose : this.pending.length;
74
+ }
75
+ /**
76
+ * Add events to the pending buffer. Returns immediately if space is
77
+ * available. Blocks (awaits) if the buffer is full — caller pauses until
78
+ * drain frees space.
79
+ *
80
+ * @param {...unknown} items
81
+ * @returns {Promise<void>}
82
+ */
83
+ async enqueue(...items) {
84
+ if (this.closed) return;
85
+ while (this.pending.length + items.length > this.config.maxQueueSize) {
86
+ if (this.closed) return;
87
+ await new Promise((resolve) => {
88
+ this.backpressureResolvers.push(resolve);
89
+ });
90
+ }
91
+ this.pending.push(...items);
92
+ this._kickDrain();
93
+ }
94
+ /**
95
+ * Block until all pending events are flushed. Resolves immediately when
96
+ * nothing is pending.
97
+ * @returns {Promise<void>}
98
+ */
99
+ async flush() {
100
+ if (this.closed) return;
101
+ if (this.draining && this._drainPromise) {
102
+ await this._drainPromise;
103
+ }
104
+ if (this.pending.length === 0) return;
105
+ await new Promise((resolve) => {
106
+ this.flushResolvers.push(resolve);
107
+ });
108
+ }
109
+ /**
110
+ * Stop accepting new events and release resources. Best-effort drain of
111
+ * any remaining pending events.
112
+ */
113
+ close() {
114
+ if (this.closed) return;
115
+ this.closed = true;
116
+ this.pendingAtClose = this.pending.length;
117
+ this.pending = [];
118
+ for (const resolve of this.backpressureResolvers) {
119
+ resolve();
120
+ }
121
+ this.backpressureResolvers = [];
122
+ for (const resolve of this.flushResolvers) {
123
+ resolve();
124
+ }
125
+ this.flushResolvers = [];
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // Internal
129
+ // ---------------------------------------------------------------------------
130
+ /** Start the drain loop if not already running. */
131
+ _kickDrain() {
132
+ if (this.draining || this.closed) return;
133
+ this.draining = true;
134
+ this._drainPromise = this._drainLoop();
135
+ }
136
+ async _drainLoop() {
137
+ while (!this.closed && this.pending.length > 0) {
138
+ const batch = this._takeBatch();
139
+ if (batch.length === 0) break;
140
+ let consecutiveFailures = 0;
141
+ let success = false;
142
+ while (!success && !this.closed) {
143
+ try {
144
+ await this.config.send(batch);
145
+ success = true;
146
+ } catch (err) {
147
+ consecutiveFailures++;
148
+ const maxFail = this.config.maxConsecutiveFailures;
149
+ if (maxFail !== void 0 && consecutiveFailures > maxFail) {
150
+ this.droppedBatches++;
151
+ this.config.onBatchDropped(batch.length, consecutiveFailures);
152
+ break;
153
+ }
154
+ let delayMs = this._backoffDelay(consecutiveFailures);
155
+ if (err instanceof RetryableError && err.retryAfterMs !== void 0) {
156
+ const clamped = Math.max(
157
+ this.config.baseDelayMs,
158
+ Math.min(err.retryAfterMs, this.config.maxDelayMs)
159
+ );
160
+ delayMs = clamped + (Math.random() - 0.5) * this.config.jitterMs;
161
+ }
162
+ await sleep(Math.max(0, delayMs));
163
+ }
164
+ }
165
+ this._releaseBackpressure();
166
+ }
167
+ this.draining = false;
168
+ for (const resolve of this.flushResolvers) {
169
+ resolve();
170
+ }
171
+ this.flushResolvers = [];
172
+ }
173
+ /**
174
+ * Take up to maxBatchSize (and maxBatchBytes) items from the pending buffer.
175
+ * @returns {unknown[]}
176
+ */
177
+ _takeBatch() {
178
+ const maxSize = this.config.maxBatchSize;
179
+ const maxBytes = this.config.maxBatchBytes;
180
+ const batch = [];
181
+ for (let i = 0; i < maxSize && this.pending.length > 0; i++) {
182
+ const item = this.pending.shift();
183
+ if (maxBytes !== void 0 && batch.length > 0) {
184
+ const itemStr = JSON.stringify(item);
185
+ const batchStr = JSON.stringify(batch) + "," + itemStr;
186
+ if (new TextEncoder().encode(batchStr).length > maxBytes) {
187
+ this.pending.unshift(item);
188
+ break;
189
+ }
190
+ }
191
+ batch.push(item);
192
+ }
193
+ return batch;
194
+ }
195
+ /**
196
+ * Exponential backoff with jitter.
197
+ * @param {number} attempt - 1-indexed attempt count
198
+ * @returns {number} delay in ms
199
+ */
200
+ _backoffDelay(attempt) {
201
+ const base = Math.min(
202
+ this.config.baseDelayMs * Math.pow(2, attempt - 1),
203
+ this.config.maxDelayMs
204
+ );
205
+ const jitter = (Math.random() - 0.5) * this.config.jitterMs;
206
+ return Math.max(0, base + jitter);
207
+ }
208
+ /** Wake up backpressure waiters now that space has freed. */
209
+ _releaseBackpressure() {
210
+ const free = this.config.maxQueueSize - this.pending.length;
211
+ const toRelease = Math.min(free, this.backpressureResolvers.length);
212
+ for (let i = 0; i < toRelease; i++) {
213
+ const resolve = this.backpressureResolvers.shift();
214
+ resolve?.();
215
+ }
216
+ }
217
+ };
218
+
219
+ // src/hbridge/transport/StdioTransport.mjs
220
+ var DEFAULT_BATCH_SIZE = 100;
221
+ var DEFAULT_QUEUE_SIZE = 1e4;
222
+ var BASE_DELAY_MS = 200;
223
+ var MAX_DELAY_MS = 4e3;
224
+ var JITTER_MS = 500;
225
+ var STATE = {
226
+ IDLE: "idle",
227
+ CONNECTED: "connected",
228
+ CLOSING: "closing",
229
+ CLOSED: "closed"
230
+ };
231
+ var StdioTransport = class {
232
+ /**
233
+ * @param {import('child_process').ChildProcess} child
234
+ * @param {object} [opts]
235
+ * @param {number} [opts.maxBatchSize=100] - Max items per stdin write
236
+ * @param {number} [opts.maxQueueSize=10000] - Max pending before backpressure
237
+ * @param {(err: Error) => void} [opts.onError] - Stderr parse error callback
238
+ */
239
+ constructor(child, opts = {}) {
240
+ this.child = child;
241
+ this._onData = null;
242
+ this._onClose = null;
243
+ this._onConnect = null;
244
+ this._state = STATE.IDLE;
245
+ this._onError = opts.onError ?? ((err) => {
246
+ process.stderr.write(`[StdioTransport] parse error: ${err.message}
247
+ `);
248
+ });
249
+ this._transcriptPath = opts.transcriptPath;
250
+ if (this._transcriptPath === void 0) {
251
+ this._transcriptPath = join(homedir(), ".hbridge_transcript.jsonl");
252
+ }
253
+ this._transcriptStream = null;
254
+ this._uploader = new SerialBatchEventUploader({
255
+ maxBatchSize: opts.maxBatchSize ?? DEFAULT_BATCH_SIZE,
256
+ maxQueueSize: opts.maxQueueSize ?? DEFAULT_QUEUE_SIZE,
257
+ baseDelayMs: BASE_DELAY_MS,
258
+ maxDelayMs: MAX_DELAY_MS,
259
+ jitterMs: JITTER_MS,
260
+ send: (batch) => this._writeBatch(batch)
261
+ });
262
+ }
263
+ get droppedBatchCount() {
264
+ return this._uploader.droppedBatchCount;
265
+ }
266
+ // ── Transport interface ──────────────────────────────────────────────
267
+ /**
268
+ * Write a single message to stdin.
269
+ * @param {import('./types.mjs').StdoutMessage} message
270
+ * @returns {Promise<void>}
271
+ */
272
+ async write(message) {
273
+ await this._uploader.enqueue(message);
274
+ }
275
+ /**
276
+ * Write multiple messages as a batch (preserves order).
277
+ * @param {import('./types.mjs').StdoutMessage[]} messages
278
+ * @returns {Promise<void>}
279
+ */
280
+ async writeBatch(messages) {
281
+ await this._uploader.enqueue(...messages);
282
+ }
283
+ /** Graceful close — drains pending writes then releases resources. */
284
+ close() {
285
+ if (this._state === STATE.CLOSED || this._state === STATE.CLOSING) return;
286
+ this._state = STATE.CLOSING;
287
+ this._uploader.close();
288
+ if (this._transcriptStream) {
289
+ this._transcriptStream.end();
290
+ this._transcriptStream = null;
291
+ }
292
+ if (this._rl) {
293
+ this._rl.close();
294
+ this._rl = null;
295
+ }
296
+ this._state = STATE.CLOSED;
297
+ this._onClose?.(0);
298
+ }
299
+ /** @returns {string} */
300
+ getStateLabel() {
301
+ return this._state;
302
+ }
303
+ /** @returns {boolean} */
304
+ isConnectedStatus() {
305
+ return this._state === STATE.CONNECTED;
306
+ }
307
+ /**
308
+ * @param {import('./types.mjs').OnDataCallback} cb
309
+ */
310
+ setOnData(cb) {
311
+ this._onData = cb;
312
+ }
313
+ /**
314
+ * @param {import('./types.mjs').OnCloseCallback} cb
315
+ */
316
+ setOnClose(cb) {
317
+ this._onClose = cb;
318
+ }
319
+ /**
320
+ * @param {import('./types.mjs').OnConnectCallback} cb
321
+ */
322
+ setOnConnect(cb) {
323
+ this._onConnect = cb;
324
+ }
325
+ /** Initiate the connection — start reading stdout. */
326
+ connect() {
327
+ if (this._state !== STATE.IDLE) return;
328
+ this._state = STATE.CONNECTED;
329
+ this._rl = createInterface({ input: this.child.stdout });
330
+ if (this._transcriptPath) {
331
+ this._transcriptStream = createWriteStream(this._transcriptPath, { flags: "a" });
332
+ this._transcriptStream.on("error", (err) => {
333
+ process.stderr.write(`[StdioTransport] transcript error: ${err.message}
334
+ `);
335
+ this._transcriptStream = null;
336
+ });
337
+ }
338
+ this._rl.on("line", (line) => {
339
+ if (this._transcriptStream) {
340
+ this._transcriptStream.write(line + "\n");
341
+ }
342
+ try {
343
+ this._onData?.(line);
344
+ } catch (err) {
345
+ this._onError(
346
+ err instanceof Error ? err : new Error(String(err))
347
+ );
348
+ }
349
+ });
350
+ this.child.stderr.on("data", (d) => {
351
+ process.stderr.write(`[claude] ${d.toString()}`);
352
+ });
353
+ this.child.on("error", (err) => {
354
+ this._state = STATE.CLOSED;
355
+ this._onClose?.(1);
356
+ });
357
+ this.child.on("close", (code) => {
358
+ this._state = STATE.CLOSED;
359
+ this._onClose?.(code ?? 1);
360
+ });
361
+ this._onConnect?.();
362
+ }
363
+ /**
364
+ * SSE-style sequence number (not used by stdio; always 0).
365
+ * @returns {number}
366
+ */
367
+ getLastSequenceNum() {
368
+ return 0;
369
+ }
370
+ // ── Internal ─────────────────────────────────────────────────────────
371
+ /**
372
+ * Safely JSON-stringify a message for NDJSON transport.
373
+ * Escapes U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR)
374
+ * which can break JavaScript readline line-splitting semantics.
375
+ * @param {unknown} msg
376
+ * @returns {string}
377
+ */
378
+ _ndjsonStringify(msg) {
379
+ const str = JSON.stringify(msg);
380
+ return str.replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
381
+ }
382
+ /**
383
+ * Actually write a batch of serialized JSON to stdin.
384
+ * Called by SerialBatchEventUploader.send().
385
+ * @param {unknown[]} batch
386
+ * @returns {Promise<void>}
387
+ */
388
+ async _writeBatch(batch) {
389
+ if (this._state === STATE.CLOSED || this._state === STATE.CLOSING) {
390
+ return;
391
+ }
392
+ let payload = "";
393
+ for (const msg of batch) {
394
+ payload += this._ndjsonStringify(msg) + "\n";
395
+ }
396
+ return new Promise((resolve, reject) => {
397
+ const canContinue = this.child.stdin.write(payload, (err) => {
398
+ if (err) reject(err);
399
+ else resolve();
400
+ });
401
+ if (!canContinue) {
402
+ this.child.stdin.once("drain", resolve);
403
+ }
404
+ });
405
+ }
406
+ };
407
+
408
+ // src/hbridge/bridgeMessaging.mjs
409
+ var BoundedUUIDSet = class {
410
+ /**
411
+ * @param {number} capacity
412
+ */
413
+ constructor(capacity) {
414
+ this.capacity = capacity;
415
+ this.ring = new Array(capacity);
416
+ this.set = /* @__PURE__ */ new Set();
417
+ this.writeIdx = 0;
418
+ }
419
+ /**
420
+ * @param {string} uuid
421
+ */
422
+ add(uuid) {
423
+ if (this.set.has(uuid)) return;
424
+ const evicted = this.ring[this.writeIdx];
425
+ if (evicted !== void 0) {
426
+ this.set.delete(evicted);
427
+ }
428
+ this.ring[this.writeIdx] = uuid;
429
+ this.set.add(uuid);
430
+ this.writeIdx = (this.writeIdx + 1) % this.capacity;
431
+ }
432
+ /**
433
+ * @param {string} uuid
434
+ * @returns {boolean}
435
+ */
436
+ has(uuid) {
437
+ return this.set.has(uuid);
438
+ }
439
+ clear() {
440
+ this.set.clear();
441
+ this.ring.fill(void 0);
442
+ this.writeIdx = 0;
443
+ }
444
+ };
445
+
446
+ // src/hbridge/session.mjs
447
+ var DEFAULT_TASK_TIMEOUT_MS = 0;
448
+ var DEFAULT_MAX_AUTO_RESPOND = 5;
449
+ var DEFAULT_PERMISSION_MODE = "approve";
450
+ var Session = class {
451
+ /**
452
+ * @param {object} opts
453
+ * @param {string} opts.taskId
454
+ * @param {string} opts.prompt
455
+ * @param {string} [opts.cwd]
456
+ * @param {number} [opts.taskTimeoutMs]
457
+ * @param {number} [opts.maxAutoRespond]
458
+ * @param {"bypass"|"approve"} [opts.permissionMode]
459
+ * @param {(session: Session) => void} [opts.onComplete]
460
+ * @param {(session: Session, reason: string) => void} [opts.onError]
461
+ */
462
+ constructor(opts = {}) {
463
+ this.taskId = opts.taskId || "unknown";
464
+ this.prompt = opts.prompt || "";
465
+ this.status = "idle";
466
+ this.result = "";
467
+ this.exitCode = null;
468
+ this.usage = null;
469
+ this.startedAt = 0;
470
+ this._cwd = opts.cwd || void 0;
471
+ this._taskTimeoutMs = opts.taskTimeoutMs ?? DEFAULT_TASK_TIMEOUT_MS;
472
+ this._maxAutoRespond = opts.maxAutoRespond ?? DEFAULT_MAX_AUTO_RESPOND;
473
+ this._skipPermissions = opts.skipPermissions === true;
474
+ this._permissionMode = opts.permissionMode ?? DEFAULT_PERMISSION_MODE;
475
+ this._onComplete = opts.onComplete || null;
476
+ this._onError = opts.onError || null;
477
+ this.child = null;
478
+ this.transport = null;
479
+ this._recentUUIDs = new BoundedUUIDSet(2e3);
480
+ this._subscribers = /* @__PURE__ */ new Set();
481
+ this._msgTextProgress = /* @__PURE__ */ new Map();
482
+ this._streamTextAccum = 0;
483
+ this._autoRespondCount = 0;
484
+ this._pendingPermission = null;
485
+ this._timeoutTimer = null;
486
+ this._resolveTask = null;
487
+ this._taskPromise = null;
488
+ }
489
+ // ── Public API ───────────────────────────────────────────────────────
490
+ /**
491
+ * Spawn Claude Code, connect transport, send prompt.
492
+ * Returns immediately — task runs asynchronously.
493
+ * @returns {Promise<{task_id: string, status: string}>}
494
+ */
495
+ async start() {
496
+ if (this.status !== "idle") {
497
+ throw new Error(`Session ${this.taskId} already started (${this.status})`);
498
+ }
499
+ this.status = "spawning";
500
+ this.startedAt = Date.now();
501
+ this.result = "";
502
+ this.exitCode = null;
503
+ this.usage = null;
504
+ this._autoRespondCount = 0;
505
+ this._msgTextProgress.clear();
506
+ this._streamTextAccum = 0;
507
+ const cwd = this._cwd || process.cwd();
508
+ const isWin = process.platform === "win32";
509
+ const claudeArgs = [
510
+ "@anthropic-ai/claude-code",
511
+ "--print",
512
+ "--input-format",
513
+ "stream-json",
514
+ "--output-format",
515
+ "stream-json",
516
+ "--verbose"
517
+ ];
518
+ if (this._skipPermissions) claudeArgs.push("--dangerously-skip-permissions");
519
+ const cmd2 = isWin ? "cmd.exe" : "npx";
520
+ const args2 = isWin ? ["/d", "/s", "/c", `npx.cmd ${claudeArgs.join(" ")}`] : claudeArgs;
521
+ try {
522
+ this.child = spawn(cmd2, args2, {
523
+ stdio: ["pipe", "pipe", "pipe"],
524
+ cwd,
525
+ env: { ...process.env }
526
+ });
527
+ } catch (err) {
528
+ process.stderr.write(`[session] spawn error: ${err.message}
529
+ `);
530
+ this.status = "failed";
531
+ this.result = err.message;
532
+ this.exitCode = 1;
533
+ this._notifyError(err.message);
534
+ return { task_id: this.taskId, status: "failed" };
535
+ }
536
+ this.transport = new StdioTransport(this.child, { transcriptPath: null });
537
+ this.transport.setOnData((line) => {
538
+ try {
539
+ const LS = String.fromCharCode(8232);
540
+ const PS = String.fromCharCode(8233);
541
+ const sanitized = line.replaceAll(LS, "\\u2028").replaceAll(PS, "\\u2029");
542
+ const msg2 = JSON.parse(sanitized);
543
+ this._onMessage(msg2);
544
+ } catch (e) {
545
+ process.stderr.write(`[claude:stdout] non-JSON (${line.length}B): ${line.slice(0, 200)}
546
+ `);
547
+ }
548
+ });
549
+ this.transport.setOnClose((code) => {
550
+ process.stderr.write(`[session] ${this.taskId}: transport closed (code=${code})
551
+ `);
552
+ if (this.status === "running" || this.status === "spawning") {
553
+ this._failTask(`Claude process exited (code=${code})`);
554
+ }
555
+ });
556
+ this.transport.connect();
557
+ this.status = "running";
558
+ if (this._taskTimeoutMs > 0) {
559
+ this._timeoutTimer = setTimeout(() => {
560
+ process.stderr.write(`[session] ${this.taskId}: timeout after ${this._taskTimeoutMs / 1e3}s
561
+ `);
562
+ this._failTask("timeout");
563
+ }, this._taskTimeoutMs);
564
+ }
565
+ this._taskPromise = new Promise((resolve) => {
566
+ this._resolveTask = resolve;
567
+ });
568
+ const msg = {
569
+ type: "user",
570
+ session_id: "",
571
+ message: { role: "user", content: this.prompt },
572
+ parent_tool_use_id: null
573
+ };
574
+ try {
575
+ await this.transport.write(msg);
576
+ } catch (err) {
577
+ process.stderr.write(`[session] ${this.taskId}: transport write error: ${err.message}
578
+ `);
579
+ this._failTask(`write error: ${err.message}`);
580
+ }
581
+ return { task_id: this.taskId, status: "created" };
582
+ }
583
+ /**
584
+ * Cancel the running task.
585
+ * @returns {boolean}
586
+ */
587
+ cancel() {
588
+ if (this.status !== "running") return false;
589
+ process.stderr.write(`[session] cancelling task ${this.taskId}
590
+ `);
591
+ this.transport?.write({
592
+ type: "control_request",
593
+ request_id: `cancel_${this.taskId}`,
594
+ request: { subtype: "interrupt" }
595
+ }).catch(() => {
596
+ });
597
+ setTimeout(() => {
598
+ if (this.child && !this.child.killed) {
599
+ try {
600
+ this.child.kill();
601
+ } catch {
602
+ }
603
+ }
604
+ }, 500);
605
+ this._failTask("cancelled");
606
+ return true;
607
+ }
608
+ /**
609
+ * Respond to a pending permission request (can_use_tool).
610
+ * @param {"allow"|"deny"} behavior
611
+ * @param {object} [updatedInput] - Modified tool input for "allow"
612
+ * @param {string} [message] - Denial reason for "deny"
613
+ * @returns {boolean} true if a pending request was responded to
614
+ */
615
+ respondPermission(behavior, updatedInput, message) {
616
+ if (!this._pendingPermission) return false;
617
+ const req = this._pendingPermission;
618
+ this._pendingPermission = null;
619
+ const payload = behavior === "allow" ? { behavior: "allow", updatedInput: updatedInput || req.input } : { behavior: "deny", message: message || "Permission denied by Hermes" };
620
+ process.stderr.write(`[session] ${this.taskId} permission: ${behavior} for ${req.toolName}
621
+ `);
622
+ this.transport?.write({
623
+ type: "control_response",
624
+ response_id: req.requestId,
625
+ response: { subtype: "success", response: payload }
626
+ }).catch(() => {
627
+ });
628
+ return true;
629
+ }
630
+ /** @returns {object|null} The pending permission request, if any */
631
+ getPendingPermission() {
632
+ return this._pendingPermission;
633
+ }
634
+ // ── SSE subscribers ─────────────────────────────────────────────────
635
+ /** @param {{ write: (data: string) => void }} subscriber */
636
+ subscribe(subscriber) {
637
+ this._subscribers.add(subscriber);
638
+ }
639
+ /** @param {{ write: (data: string) => void }} subscriber */
640
+ unsubscribe(subscriber) {
641
+ this._subscribers.delete(subscriber);
642
+ }
643
+ // ── Internal: message handling ──────────────────────────────────────
644
+ /**
645
+ * Handle a parsed NDJSON message from Claude's stdout.
646
+ * @param {Record<string,unknown>} msg
647
+ */
648
+ _onMessage(msg) {
649
+ process.stderr.write(`[session:msg] ${this.taskId} role=${msg.role || msg.type || "?"}
650
+ `);
651
+ if (msg.uuid && this._recentUUIDs.has(
652
+ /** @type {string} */
653
+ msg.uuid
654
+ )) return;
655
+ if (msg.uuid) this._recentUUIDs.add(
656
+ /** @type {string} */
657
+ msg.uuid
658
+ );
659
+ if (msg.type === "keep_alive") return;
660
+ if (msg.type === "system" && msg.subtype === "init") return;
661
+ if (msg.type === "control_request") {
662
+ const reqId = (
663
+ /** @type {string} */
664
+ msg.request_id ?? ""
665
+ );
666
+ const subtype = (
667
+ /** @type {string|undefined} */
668
+ msg.request?.subtype
669
+ );
670
+ process.stderr.write(`[session] ${this.taskId} control_request: ${subtype || "?"} (id=${reqId})
671
+ `);
672
+ if (subtype === "can_use_tool") {
673
+ if (this._permissionMode === "bypass") {
674
+ process.stderr.write(`[session] ${this.taskId} bypass permission: ${msg.request?.tool_name ?? ""}
675
+ `);
676
+ this.transport?.write({
677
+ type: "control_response",
678
+ response_id: reqId,
679
+ response: {
680
+ subtype: "success",
681
+ response: { behavior: "allow", updatedInput: msg.request?.input ?? {} }
682
+ }
683
+ }).catch(() => {
684
+ });
685
+ return;
686
+ }
687
+ this._pendingPermission = {
688
+ requestId: reqId,
689
+ toolName: (
690
+ /** @type {string} */
691
+ msg.request?.tool_name ?? ""
692
+ ),
693
+ input: msg.request?.input ?? {},
694
+ toolUseId: (
695
+ /** @type {string} */
696
+ msg.request?.tool_use_id ?? ""
697
+ ),
698
+ timestamp: Date.now()
699
+ };
700
+ process.stderr.write(`[session] ${this.taskId} awaiting permission: ${this._pendingPermission.toolName}
701
+ `);
702
+ this._emitTaskEvent("permission_request", {
703
+ request_id: reqId,
704
+ task_id: this.taskId,
705
+ tool_name: this._pendingPermission.toolName,
706
+ input: this._pendingPermission.input,
707
+ tool_use_id: this._pendingPermission.toolUseId
708
+ });
709
+ return;
710
+ }
711
+ this.transport?.write({
712
+ type: "control_response",
713
+ response_id: reqId,
714
+ response: { subtype: "success" }
715
+ }).catch(() => {
716
+ });
717
+ return;
718
+ }
719
+ if (msg.type === "tool_progress") {
720
+ const toolName = (
721
+ /** @type {string} */
722
+ msg.tool_name ?? ""
723
+ );
724
+ const elapsed = (
725
+ /** @type {number} */
726
+ msg.elapsed_time_seconds ?? 0
727
+ );
728
+ process.stderr.write(`[session] ${this.taskId} tool_progress: ${toolName} ${elapsed}s
729
+ `);
730
+ this._emitTaskEvent("tool_progress", { tool_name: toolName, elapsed });
731
+ return;
732
+ }
733
+ if (msg.type === "auth_status") {
734
+ if (msg.isAuthenticating) process.stderr.write(`[session] ${this.taskId} auth: authenticating...
735
+ `);
736
+ if (msg.error) process.stderr.write(`[session] ${this.taskId} auth error: ${msg.error}
737
+ `);
738
+ return;
739
+ }
740
+ if (msg.type === "rate_limit_event") {
741
+ const status = (
742
+ /** @type {string|undefined} */
743
+ msg.rate_limit_info?.status
744
+ );
745
+ process.stderr.write(`[session] ${this.taskId} rate_limit: ${status || "?"}
746
+ `);
747
+ return;
748
+ }
749
+ if (msg.type === "session_state_changed") {
750
+ const state = (
751
+ /** @type {string|undefined} */
752
+ msg.state
753
+ );
754
+ process.stderr.write(`[session] ${this.taskId} session_state: ${state}
755
+ `);
756
+ if (state === "idle") {
757
+ process.stderr.write(`[session] ${this.taskId} session idle \u2192 finishing
758
+ `);
759
+ this._finishTask(0);
760
+ }
761
+ return;
762
+ }
763
+ if (msg.type === "stream_event") {
764
+ const event = (
765
+ /** @type {any} */
766
+ msg.event
767
+ );
768
+ if (event?.type === "content_block_delta" && event.delta?.type === "text_delta" && event.delta.text) {
769
+ const delta = (
770
+ /** @type {string} */
771
+ event.delta.text
772
+ );
773
+ this.result += delta;
774
+ this._streamTextAccum += delta.length;
775
+ this._emitChunk(delta);
776
+ }
777
+ return;
778
+ }
779
+ const msgId = (
780
+ /** @type {string|undefined} */
781
+ msg.message?.id || msg.id
782
+ );
783
+ const blocks = msg.content || msg.message && msg.message.content;
784
+ if (blocks) {
785
+ let fullText = "";
786
+ for (const block of blocks) {
787
+ if (block.type === "text") {
788
+ fullText += block.text;
789
+ } else if (block.type === "tool_use") {
790
+ fullText += `
791
+ <tool_use name="${block.name}">
792
+ ${JSON.stringify(block.input)}
793
+ </tool_use>
794
+ `;
795
+ } else if (block.type === "tool_result") {
796
+ let resultText = "";
797
+ if (typeof block.content === "string") {
798
+ resultText = block.content;
799
+ } else if (Array.isArray(block.content)) {
800
+ for (const item of block.content) {
801
+ if (item.type === "text") resultText += item.text;
802
+ }
803
+ }
804
+ fullText += `
805
+ <tool_result>
806
+ ${resultText}
807
+ </tool_result>
808
+ `;
809
+ }
810
+ }
811
+ if (fullText) {
812
+ let delta;
813
+ if (msgId) {
814
+ const prev = this._msgTextProgress.get(msgId);
815
+ const prevText = prev?.lastText || "";
816
+ delta = fullText.slice(prevText.length);
817
+ if (prev) {
818
+ prev.lastText = fullText;
819
+ } else {
820
+ this._msgTextProgress.set(msgId, { lastText: fullText });
821
+ }
822
+ } else {
823
+ delta = fullText.slice(this._streamTextAccum);
824
+ }
825
+ if (delta) {
826
+ this.result += delta;
827
+ this._emitChunk(delta);
828
+ }
829
+ }
830
+ }
831
+ if (msg.type === "user") {
832
+ if (this._autoRespondCount < this._maxAutoRespond) {
833
+ this._autoRespondCount++;
834
+ process.stderr.write(`[session] ${this.taskId} auto-respond #${this._autoRespondCount}/${this._maxAutoRespond}
835
+ `);
836
+ this.transport?.write({
837
+ type: "user",
838
+ session_id: (
839
+ /** @type {string} */
840
+ msg.session_id ?? ""
841
+ ),
842
+ message: { role: "user", content: "Continue. Do not ask for confirmation." },
843
+ parent_tool_use_id: null
844
+ }).catch(() => {
845
+ });
846
+ }
847
+ return;
848
+ }
849
+ if (msg.stop_reason || msg.type === "result") {
850
+ const totalCostUsd = (
851
+ /** @type {number|undefined} */
852
+ msg.total_cost_usd
853
+ );
854
+ const usage = (
855
+ /** @type {Record<string,number>|undefined} */
856
+ msg.usage
857
+ );
858
+ if (totalCostUsd !== void 0 || usage !== void 0) {
859
+ this.usage = {
860
+ total_cost_usd: totalCostUsd ?? 0,
861
+ input_tokens: usage?.input_tokens ?? 0,
862
+ output_tokens: usage?.output_tokens ?? 0,
863
+ cache_creation_input_tokens: usage?.cache_creation_input_tokens ?? 0,
864
+ cache_read_input_tokens: usage?.cache_read_input_tokens ?? 0
865
+ };
866
+ process.stderr.write(
867
+ `[session] ${this.taskId} usage: $${this.usage.total_cost_usd} (in=${this.usage.input_tokens} out=${this.usage.output_tokens} cache_creation=${this.usage.cache_creation_input_tokens} cache_read=${this.usage.cache_read_input_tokens})
868
+ `
869
+ );
870
+ }
871
+ const isError = typeof msg.subtype === "string" && msg.subtype.startsWith("error_");
872
+ this._finishTask(isError ? 1 : 0);
873
+ }
874
+ }
875
+ // ── Internal: lifecycle helpers ─────────────────────────────────────
876
+ _finishTask(exitCode) {
877
+ if (this.status === "done" || this.status === "failed") return;
878
+ this.status = "done";
879
+ this.exitCode = exitCode;
880
+ this._clearTimeout();
881
+ this._emitDone(exitCode);
882
+ this._cleanupSubscribers();
883
+ this._cleanup();
884
+ if (this._resolveTask) {
885
+ this._resolveTask();
886
+ this._resolveTask = null;
887
+ }
888
+ this._onComplete?.(this);
889
+ }
890
+ _failTask(reason) {
891
+ if (this.status === "done" || this.status === "failed") return;
892
+ this.status = "failed";
893
+ this.result = reason;
894
+ this.exitCode = 1;
895
+ this._clearTimeout();
896
+ this._emitError(reason);
897
+ this._cleanupSubscribers();
898
+ this._cleanup();
899
+ if (this._resolveTask) {
900
+ this._resolveTask();
901
+ this._resolveTask = null;
902
+ }
903
+ this._onError?.(this, reason);
904
+ }
905
+ _cleanup() {
906
+ if (this.transport) {
907
+ try {
908
+ this.transport.close();
909
+ } catch {
910
+ }
911
+ this.transport = null;
912
+ }
913
+ if (this.child && !this.child.killed) {
914
+ try {
915
+ this.child.kill();
916
+ } catch {
917
+ }
918
+ }
919
+ this.child = null;
920
+ }
921
+ _clearTimeout() {
922
+ if (this._timeoutTimer) {
923
+ clearTimeout(this._timeoutTimer);
924
+ this._timeoutTimer = null;
925
+ }
926
+ }
927
+ // ── Internal: SSE emit helpers ─────────────────────────────────────
928
+ _emitChunk(text) {
929
+ const sse = `data: ${JSON.stringify({ type: "chunk", text })}
930
+
931
+ `;
932
+ for (const sub of this._subscribers) {
933
+ try {
934
+ sub.write(sse);
935
+ } catch {
936
+ }
937
+ }
938
+ }
939
+ _emitTaskEvent(eventType, data) {
940
+ const sse = `data: ${JSON.stringify({ type: eventType, ...data })}
941
+
942
+ `;
943
+ for (const sub of this._subscribers) {
944
+ try {
945
+ sub.write(sse);
946
+ } catch {
947
+ }
948
+ }
949
+ }
950
+ _emitDone(exitCode) {
951
+ const sse = `data: ${JSON.stringify({ type: "done", exitCode })}
952
+
953
+ `;
954
+ for (const sub of this._subscribers) {
955
+ try {
956
+ sub.write(sse);
957
+ } catch {
958
+ }
959
+ }
960
+ }
961
+ _emitError(reason) {
962
+ const sse = `data: ${JSON.stringify({ type: "error", reason })}
963
+
964
+ `;
965
+ for (const sub of this._subscribers) {
966
+ try {
967
+ sub.write(sse);
968
+ } catch {
969
+ }
970
+ }
971
+ }
972
+ _cleanupSubscribers() {
973
+ this._subscribers.clear();
974
+ }
975
+ /** @param {string} reason */
976
+ _notifyError(reason) {
977
+ this._onError?.(this, reason);
978
+ }
979
+ };
980
+
981
+ // src/hbridge/persistence.mjs
982
+ import { readFileSync, appendFileSync, writeFileSync, existsSync } from "fs";
983
+ import { join as join2 } from "path";
984
+ import { homedir as homedir2 } from "os";
985
+ var TASKS_FILE = join2(homedir2(), ".hbridge_tasks.jsonl");
986
+ var MAX_TASKS = 2e3;
987
+ function appendCompletedTask(task) {
988
+ if (!task || !task.id) return;
989
+ const record = {
990
+ id: task.id,
991
+ prompt: task.prompt || "",
992
+ status: task.status || "unknown",
993
+ result: task.result || "",
994
+ exitCode: task.exitCode ?? null,
995
+ usage: task.usage ?? null,
996
+ completedAt: Date.now()
997
+ };
998
+ try {
999
+ appendFileSync(TASKS_FILE, JSON.stringify(record) + "\n", "utf8");
1000
+ } catch (e) {
1001
+ process.stderr.write(`[persistence] append error: ${e.message}
1002
+ `);
1003
+ }
1004
+ }
1005
+ function loadCompletedTasks() {
1006
+ if (!existsSync(TASKS_FILE)) return [];
1007
+ try {
1008
+ const content = readFileSync(TASKS_FILE, "utf8");
1009
+ const tasks = [];
1010
+ for (const line of content.split("\n").filter(Boolean)) {
1011
+ try {
1012
+ tasks.push(JSON.parse(line));
1013
+ } catch {
1014
+ }
1015
+ }
1016
+ return tasks;
1017
+ } catch {
1018
+ return [];
1019
+ }
1020
+ }
1021
+ function findCompletedTask(taskId) {
1022
+ if (!existsSync(TASKS_FILE)) return null;
1023
+ try {
1024
+ const content = readFileSync(TASKS_FILE, "utf8");
1025
+ for (const line of content.split("\n").filter(Boolean)) {
1026
+ try {
1027
+ const task = JSON.parse(line);
1028
+ if (task.id === taskId) return task;
1029
+ } catch {
1030
+ }
1031
+ }
1032
+ } catch {
1033
+ }
1034
+ return null;
1035
+ }
1036
+ function trimCompletedTasks() {
1037
+ if (!existsSync(TASKS_FILE)) return;
1038
+ try {
1039
+ const content = readFileSync(TASKS_FILE, "utf8");
1040
+ const lines = content.split("\n").filter(Boolean);
1041
+ if (lines.length <= MAX_TASKS) return;
1042
+ const trimmed = lines.slice(lines.length - MAX_TASKS);
1043
+ writeFileSync(TASKS_FILE, trimmed.join("\n") + "\n", "utf8");
1044
+ process.stderr.write(`[persistence] trimmed ${lines.length - trimmed.length} tasks (${trimmed.length} kept)
1045
+ `);
1046
+ } catch {
1047
+ }
1048
+ }
1049
+
1050
+ // src/hbridge/state.mjs
1051
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
1052
+ import { join as join3 } from "path";
1053
+ import { homedir as homedir3 } from "os";
1054
+ var STATE_FILE = join3(homedir3(), ".hbridge_state.json");
1055
+ var DEFAULT_STATE = {
1056
+ running: false,
1057
+ port: 9190,
1058
+ tasks: 0,
1059
+ startedAt: 0,
1060
+ updatedAt: 0,
1061
+ lastClientIP: "",
1062
+ lastActiveAt: 0
1063
+ };
1064
+ function readState() {
1065
+ if (!existsSync2(STATE_FILE)) return { ...DEFAULT_STATE };
1066
+ try {
1067
+ return { ...DEFAULT_STATE, ...JSON.parse(readFileSync2(STATE_FILE, "utf8")) };
1068
+ } catch {
1069
+ return { ...DEFAULT_STATE };
1070
+ }
1071
+ }
1072
+ function writeState(partial) {
1073
+ const current = readState();
1074
+ const next = { ...current, ...partial, updatedAt: Date.now() };
1075
+ writeFileSync2(STATE_FILE, JSON.stringify(next, null, 2));
1076
+ return next;
1077
+ }
1078
+ function markRunning(port) {
1079
+ return writeState({ running: true, port, startedAt: Date.now() });
1080
+ }
1081
+ function markStopped() {
1082
+ return writeState({ running: false, port: 9190, tasks: 0 });
1083
+ }
1084
+ function incrementTasks() {
1085
+ const s = readState();
1086
+ return writeState({ tasks: s.tasks + 1 });
1087
+ }
1088
+
1089
+ // src/hbridge/bridge.mjs
1090
+ var DEFAULT_MAX_CONCURRENT = 3;
1091
+ var TRIM_INTERVAL = 100;
1092
+ var Bridge = class {
1093
+ /**
1094
+ * @param {object} [opts]
1095
+ * @param {number} [opts.maxConcurrent] - Max parallel sessions (default 3)
1096
+ * @param {number} [opts.taskTimeoutMs] - Per-task timeout (0 = no timeout)
1097
+ * @param {"bypass"|"approve"} [opts.permissionMode] - Default permission mode (default "approve")
1098
+ * @param {string} [opts.cwd] - Default working directory
1099
+ */
1100
+ constructor(opts = {}) {
1101
+ this._maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
1102
+ this._taskTimeoutMs = opts.taskTimeoutMs ?? 0;
1103
+ this._permissionMode = opts.permissionMode ?? "approve";
1104
+ this._cwd = opts.cwd || void 0;
1105
+ this._sessions = /* @__PURE__ */ new Map();
1106
+ this._pendingQueue = [];
1107
+ this._completedTasks = /* @__PURE__ */ new Map();
1108
+ this._taskSubscribers = /* @__PURE__ */ new Map();
1109
+ this._persistCount = 0;
1110
+ this._loadPersistedTasks();
1111
+ }
1112
+ // ── Public API ───────────────────────────────────────────────────────
1113
+ /**
1114
+ * Create a new task. Spawns a Claude process or queues if at capacity.
1115
+ * @param {string} prompt
1116
+ * @param {string} [taskId]
1117
+ * @param {{ cwd?: string, sessionId?: string, permissionMode?: "bypass"|"approve" }} [opts]
1118
+ * @returns {Promise<{task_id: string, status: string}>}
1119
+ */
1120
+ async createTask(prompt, taskId, opts = {}) {
1121
+ const id = taskId || `task_${randomUUID()}`;
1122
+ if (this._sessions.size < this._maxConcurrent) {
1123
+ return this._startSession(prompt, id, opts);
1124
+ }
1125
+ return new Promise((resolve, reject) => {
1126
+ this._pendingQueue.push({ prompt, taskId: id, opts, resolve, reject });
1127
+ });
1128
+ }
1129
+ /**
1130
+ * Cancel a running or queued task.
1131
+ * @param {string} taskId
1132
+ * @param {{ sessionId?: string }} [_opts] - Ignored (kept for compat)
1133
+ * @returns {boolean}
1134
+ */
1135
+ cancelTask(taskId, _opts) {
1136
+ const session = this._sessions.get(taskId);
1137
+ if (session) {
1138
+ session.cancel();
1139
+ this._removeSession(taskId);
1140
+ return true;
1141
+ }
1142
+ const qIdx = this._pendingQueue.findIndex((item) => item.taskId === taskId);
1143
+ if (qIdx !== -1) {
1144
+ const item = this._pendingQueue[qIdx];
1145
+ this._pendingQueue.splice(qIdx, 1);
1146
+ item.reject(new Error("cancelled"));
1147
+ return true;
1148
+ }
1149
+ if (this._completedTasks.has(taskId)) {
1150
+ this._completedTasks.delete(taskId);
1151
+ return true;
1152
+ }
1153
+ return false;
1154
+ }
1155
+ /**
1156
+ * Get task info (no result text).
1157
+ * @param {string} taskId
1158
+ * @param {{ sessionId?: string }} [_opts] - Ignored (kept for compat)
1159
+ * @returns {object|null}
1160
+ */
1161
+ getTask(taskId, _opts) {
1162
+ const session = this._sessions.get(taskId);
1163
+ if (session) {
1164
+ return {
1165
+ id: session.taskId,
1166
+ status: session.status,
1167
+ created: session.startedAt,
1168
+ usage: session.usage ?? null
1169
+ };
1170
+ }
1171
+ const cached = this._completedTasks.get(taskId);
1172
+ if (cached) {
1173
+ return {
1174
+ id: cached.id,
1175
+ status: cached.status,
1176
+ created: cached.completedAt || 0,
1177
+ usage: cached.usage ?? null
1178
+ };
1179
+ }
1180
+ const persisted = findCompletedTask(taskId);
1181
+ if (persisted) {
1182
+ return {
1183
+ id: persisted.id,
1184
+ status: persisted.status,
1185
+ created: persisted.completedAt || 0,
1186
+ usage: persisted.usage ?? null
1187
+ };
1188
+ }
1189
+ return null;
1190
+ }
1191
+ /**
1192
+ * Get task output (includes result text).
1193
+ * @param {string} taskId
1194
+ * @param {{ sessionId?: string }} [_opts] - Ignored (kept for compat)
1195
+ * @returns {object|null}
1196
+ */
1197
+ getTaskOutput(taskId, _opts) {
1198
+ const session = this._sessions.get(taskId);
1199
+ if (session) {
1200
+ const done = session.status === "done" || session.status === "failed";
1201
+ return {
1202
+ retrieval_status: done ? "success" : "pending",
1203
+ task: {
1204
+ id: session.taskId,
1205
+ status: session.status,
1206
+ result: session.result || "",
1207
+ exitCode: session.exitCode ?? null,
1208
+ usage: session.usage ?? null
1209
+ }
1210
+ };
1211
+ }
1212
+ const cached = this._completedTasks.get(taskId);
1213
+ if (cached) {
1214
+ return {
1215
+ retrieval_status: "success",
1216
+ task: {
1217
+ id: cached.id,
1218
+ status: cached.status,
1219
+ result: cached.result || "",
1220
+ exitCode: cached.exitCode ?? null,
1221
+ usage: cached.usage ?? null
1222
+ }
1223
+ };
1224
+ }
1225
+ const persisted = findCompletedTask(taskId);
1226
+ if (persisted) {
1227
+ return {
1228
+ retrieval_status: "success",
1229
+ task: {
1230
+ id: persisted.id,
1231
+ status: persisted.status,
1232
+ result: persisted.result || "",
1233
+ exitCode: persisted.exitCode ?? null,
1234
+ usage: persisted.usage ?? null
1235
+ }
1236
+ };
1237
+ }
1238
+ return null;
1239
+ }
1240
+ /**
1241
+ * Subscribe to SSE streaming for a task.
1242
+ * @param {string} taskId
1243
+ * @param {{ write: (data: string) => void }} subscriber
1244
+ */
1245
+ subscribeTask(taskId, subscriber) {
1246
+ const session = this._sessions.get(taskId);
1247
+ if (session) {
1248
+ session.subscribe(subscriber);
1249
+ return;
1250
+ }
1251
+ if (!this._taskSubscribers.has(taskId)) {
1252
+ this._taskSubscribers.set(taskId, /* @__PURE__ */ new Set());
1253
+ }
1254
+ this._taskSubscribers.get(taskId).add(subscriber);
1255
+ }
1256
+ /**
1257
+ * Unsubscribe from SSE streaming for a task.
1258
+ * @param {string} taskId
1259
+ * @param {{ write: (data: string) => void }} subscriber
1260
+ */
1261
+ unsubscribeTask(taskId, subscriber) {
1262
+ const session = this._sessions.get(taskId);
1263
+ if (session) {
1264
+ session.unsubscribe(subscriber);
1265
+ return;
1266
+ }
1267
+ const subs = this._taskSubscribers.get(taskId);
1268
+ if (subs) {
1269
+ subs.delete(subscriber);
1270
+ if (subs.size === 0) this._taskSubscribers.delete(taskId);
1271
+ }
1272
+ }
1273
+ /**
1274
+ * Get active session count.
1275
+ * @returns {number}
1276
+ */
1277
+ getActiveCount() {
1278
+ return this._sessions.size;
1279
+ }
1280
+ /**
1281
+ * Get pending queue depth.
1282
+ * @returns {number}
1283
+ */
1284
+ getQueueDepth() {
1285
+ return this._pendingQueue.length;
1286
+ }
1287
+ /**
1288
+ * Respond to a pending permission request for a task.
1289
+ * @param {string} taskId
1290
+ * @param {"allow"|"deny"} behavior
1291
+ * @param {object} [updatedInput]
1292
+ * @param {string} [message]
1293
+ * @returns {boolean}
1294
+ */
1295
+ respondPermission(taskId, behavior, updatedInput, message) {
1296
+ const session = this._sessions.get(taskId);
1297
+ if (!session) return false;
1298
+ return session.respondPermission(behavior, updatedInput, message);
1299
+ }
1300
+ /**
1301
+ * Get pending permission request for a task.
1302
+ * @param {string} taskId
1303
+ * @returns {object|null}
1304
+ */
1305
+ getPendingPermission(taskId) {
1306
+ const session = this._sessions.get(taskId);
1307
+ if (!session) return null;
1308
+ return session.getPendingPermission();
1309
+ }
1310
+ // ── Internal ─────────────────────────────────────────────────────────
1311
+ /**
1312
+ * Start a new session for a task.
1313
+ * @param {string} prompt
1314
+ * @param {string} taskId
1315
+ * @param {object} opts
1316
+ * @returns {Promise<{task_id: string, status: string}>}
1317
+ */
1318
+ async _startSession(prompt, taskId, opts) {
1319
+ const session = new Session({
1320
+ taskId,
1321
+ prompt,
1322
+ cwd: opts?.cwd || this._cwd,
1323
+ taskTimeoutMs: this._taskTimeoutMs,
1324
+ permissionMode: opts?.permissionMode || this._permissionMode,
1325
+ skipPermissions: opts?.skipPermissions === true,
1326
+ onComplete: (s) => this._onSessionComplete(s),
1327
+ onError: (s, reason) => this._onSessionError(s, reason)
1328
+ });
1329
+ this._sessions.set(taskId, session);
1330
+ const pendingSubs = this._taskSubscribers.get(taskId);
1331
+ if (pendingSubs) {
1332
+ for (const sub of pendingSubs) {
1333
+ session.subscribe(sub);
1334
+ }
1335
+ this._taskSubscribers.delete(taskId);
1336
+ }
1337
+ writeState({ latestTask: { id: taskId, prompt, status: "running" } });
1338
+ const result = await session.start();
1339
+ return result;
1340
+ }
1341
+ /** @param {import("./session.mjs").Session} session */
1342
+ _onSessionComplete(session) {
1343
+ const record = {
1344
+ id: session.taskId,
1345
+ prompt: session.prompt,
1346
+ status: "done",
1347
+ result: session.result || "",
1348
+ exitCode: session.exitCode ?? 0,
1349
+ usage: session.usage ?? null,
1350
+ completedAt: Date.now()
1351
+ };
1352
+ this._completedTasks.set(session.taskId, record);
1353
+ appendCompletedTask(record);
1354
+ writeState({ latestTask: { id: session.taskId, prompt: session.prompt, status: "done", exitCode: session.exitCode } });
1355
+ this._removeSession(session.taskId);
1356
+ this._persistCount++;
1357
+ if (this._persistCount % TRIM_INTERVAL === 0) {
1358
+ trimCompletedTasks();
1359
+ }
1360
+ this._dequeueNext();
1361
+ }
1362
+ /** @param {import("./session.mjs").Session} session */
1363
+ _onSessionError(session, reason) {
1364
+ const record = {
1365
+ id: session.taskId,
1366
+ prompt: session.prompt,
1367
+ status: "failed",
1368
+ result: reason || "",
1369
+ exitCode: 1,
1370
+ usage: null,
1371
+ completedAt: Date.now()
1372
+ };
1373
+ this._completedTasks.set(session.taskId, record);
1374
+ appendCompletedTask(record);
1375
+ writeState({ latestTask: { id: session.taskId, prompt: session.prompt, status: "failed" } });
1376
+ this._removeSession(session.taskId);
1377
+ this._dequeueNext();
1378
+ }
1379
+ /** @param {string} taskId */
1380
+ _removeSession(taskId) {
1381
+ this._sessions.delete(taskId);
1382
+ }
1383
+ /** Start the next queued task if capacity allows. */
1384
+ _dequeueNext() {
1385
+ while (this._sessions.size < this._maxConcurrent && this._pendingQueue.length > 0) {
1386
+ const item = this._pendingQueue.shift();
1387
+ if (!item) break;
1388
+ this._startSession(item.prompt, item.taskId, item.opts).then((r) => item.resolve(r)).catch((e) => item.reject(e));
1389
+ }
1390
+ }
1391
+ /** Load persisted tasks from disk into memory cache. */
1392
+ _loadPersistedTasks() {
1393
+ try {
1394
+ const tasks = loadCompletedTasks();
1395
+ const maxCached = 500;
1396
+ const recent = tasks.slice(-maxCached);
1397
+ for (const t of recent) {
1398
+ this._completedTasks.set(t.id, t);
1399
+ }
1400
+ if (tasks.length > 0) {
1401
+ process.stderr.write(`[bridge] loaded ${tasks.length} persisted tasks (cached ${recent.length})
1402
+ `);
1403
+ }
1404
+ } catch {
1405
+ }
1406
+ }
1407
+ };
1408
+
1409
+ // src/hbridge/home.mjs
1410
+ import { createHash, randomBytes } from "crypto";
1411
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync3 } from "fs";
1412
+ import { join as join4 } from "path";
1413
+ import { homedir as homedir4 } from "os";
1414
+ var BASE52 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
1415
+ var KEY_FILE = join4(homedir4(), ".hbridge_key");
1416
+ function isHome() {
1417
+ return process.env.HBRIDGE_HOME == 1;
1418
+ }
1419
+ function homePort(cwd) {
1420
+ const hash = createHash("md5").update(Buffer.from(cwd, "utf-8")).digest();
1421
+ return 9200 + hash.readUInt16BE(0) % 600;
1422
+ }
1423
+ function homeKey(_cwd) {
1424
+ if (existsSync3(KEY_FILE)) {
1425
+ const existing = readFileSync3(KEY_FILE, "utf8").trim();
1426
+ if (existing && existing.startsWith("hb_")) {
1427
+ return existing;
1428
+ }
1429
+ }
1430
+ const bytes = randomBytes(8);
1431
+ let key = "hb_";
1432
+ for (const byte of bytes) {
1433
+ key += BASE52[byte % 52];
1434
+ }
1435
+ writeFileSync3(KEY_FILE, key, "utf8");
1436
+ return key;
1437
+ }
1438
+
1439
+ // src/hbridge/server.mjs
1440
+ var taskCount = 0;
1441
+ var startTime = Date.now();
1442
+ function createServer(expectedKey, bridge = new Bridge()) {
1443
+ return http((req, res) => {
1444
+ if (req.method === "OPTIONS") {
1445
+ res.writeHead(204);
1446
+ return res.end();
1447
+ }
1448
+ if (req.url === "/health") {
1449
+ res.writeHead(200, { "Content-Type": "application/json" });
1450
+ return res.end(JSON.stringify({ status: "ok" }));
1451
+ }
1452
+ if (!isHome()) {
1453
+ const auth = req.headers["authorization"] || "";
1454
+ const providedKey = Buffer.from(auth.split(" ")[1] || "", "base64").toString().split(":")[1];
1455
+ console.error(`AUTH: key_match=${providedKey === expectedKey}`);
1456
+ if (providedKey !== expectedKey) {
1457
+ res.writeHead(401);
1458
+ return res.end("Unauthorized");
1459
+ }
1460
+ }
1461
+ const clientIP = req.socket.remoteAddress?.replace(/^::ffff:/, "") || "unknown";
1462
+ writeState({ lastClientIP: clientIP, lastActiveAt: Date.now() });
1463
+ const isPost = req.method === "POST";
1464
+ let body = "";
1465
+ async function handle() {
1466
+ try {
1467
+ let result = {};
1468
+ let status = 200;
1469
+ const payload = body ? JSON.parse(body) : {};
1470
+ const [_, v, endpoint, actionRaw, subactionRaw] = req.url.split("/");
1471
+ const action = actionRaw ? actionRaw.split("?")[0] : "";
1472
+ const subaction = subactionRaw ? subactionRaw.split("?")[0] : "";
1473
+ if (endpoint === "task" && action === "output" && subaction === "stream") {
1474
+ const taskId = new URL(`http://localhost${req.url}`).searchParams.get("task_id");
1475
+ if (!taskId) {
1476
+ status = 400;
1477
+ result = { error: "task_id required" };
1478
+ } else {
1479
+ res.writeHead(200, {
1480
+ "Content-Type": "text/event-stream",
1481
+ "Cache-Control": "no-cache",
1482
+ Connection: "keep-alive",
1483
+ "X-Accel-Buffering": "no"
1484
+ });
1485
+ res.write(`data: ${JSON.stringify({ type: "connected", taskId })}
1486
+
1487
+ `);
1488
+ const subscriber = {
1489
+ write: (data) => {
1490
+ try {
1491
+ res.write(data);
1492
+ } catch {
1493
+ cleanup();
1494
+ }
1495
+ }
1496
+ };
1497
+ const cleanup = () => {
1498
+ try {
1499
+ bridge.unsubscribeTask(taskId, subscriber);
1500
+ } catch {
1501
+ }
1502
+ try {
1503
+ res.end();
1504
+ } catch {
1505
+ }
1506
+ };
1507
+ bridge.subscribeTask(taskId, subscriber);
1508
+ req.on("close", cleanup);
1509
+ return;
1510
+ }
1511
+ } else if (endpoint === "task" && action === "create" && isPost) {
1512
+ taskCount++;
1513
+ incrementTasks();
1514
+ const taskId = `task_${Date.now()}_${taskCount}`;
1515
+ const createOpts = {};
1516
+ if (payload.sessionId) createOpts.sessionId = payload.sessionId;
1517
+ if (payload.permission_mode) createOpts.permissionMode = payload.permission_mode;
1518
+ if (payload.skip_permissions) createOpts.skipPermissions = true;
1519
+ result = { task_id: taskId, status: "created" };
1520
+ bridge.createTask(payload.prompt, taskId, createOpts).catch((err) => {
1521
+ console.error(`[server] task ${taskId} error: ${err.message}`);
1522
+ });
1523
+ } else if (endpoint === "task" && action === "cancel" && isPost) {
1524
+ const taskId = payload.task_id;
1525
+ if (!taskId) {
1526
+ status = 400;
1527
+ result = { error: "task_id required" };
1528
+ } else {
1529
+ const cancelOpts = {};
1530
+ if (payload.sessionId) cancelOpts.sessionId = payload.sessionId;
1531
+ const ok = bridge.cancelTask(taskId, cancelOpts);
1532
+ result = ok ? { status: "cancelled", task_id: taskId } : { error: "not_found" };
1533
+ }
1534
+ } else if (endpoint === "task" && action === "permission" && isPost) {
1535
+ const taskId = payload.task_id;
1536
+ if (!taskId || !payload.behavior) {
1537
+ status = 400;
1538
+ result = { error: "task_id and behavior required" };
1539
+ } else if (!["allow", "deny"].includes(payload.behavior)) {
1540
+ status = 400;
1541
+ result = { error: 'behavior must be "allow" or "deny"' };
1542
+ } else {
1543
+ const ok = bridge.respondPermission(taskId, payload.behavior, payload.updatedInput, payload.message);
1544
+ result = ok ? { status: "responded", task_id: taskId } : { error: "no_pending_request" };
1545
+ }
1546
+ } else if (endpoint === "task" && action === "output") {
1547
+ const taskId = new URL(`http://localhost${req.url}`).searchParams.get("task_id");
1548
+ result = bridge.getTaskOutput(taskId) || { error: "not_found" };
1549
+ } else if (endpoint === "task") {
1550
+ const taskId = new URL(`http://localhost${req.url}`).searchParams.get("task_id");
1551
+ result = bridge.getTask(taskId) || { error: "not_found" };
1552
+ } else {
1553
+ status = 404;
1554
+ result = { error: "not_found" };
1555
+ }
1556
+ res.writeHead(status, { "Content-Type": "application/json" });
1557
+ res.end(JSON.stringify(result));
1558
+ } catch (e) {
1559
+ res.writeHead(400);
1560
+ res.end(JSON.stringify({ error: e.message }));
1561
+ }
1562
+ }
1563
+ if (isPost) {
1564
+ req.on("data", (d) => body += d);
1565
+ req.on("end", handle);
1566
+ } else {
1567
+ handle();
1568
+ }
1569
+ });
1570
+ }
1571
+ function startStatusBar(port) {
1572
+ function render() {
1573
+ const uptime = Math.floor((Date.now() - startTime) / 6e4);
1574
+ process.stdout.write(`\r hbridge: on | port: ${port} | ${taskCount} tasks | \u2191 ${uptime}min `);
1575
+ }
1576
+ render();
1577
+ return setInterval(render, 5e3);
1578
+ }
1579
+ function stopStatusBar(intervalId) {
1580
+ clearInterval(intervalId);
1581
+ }
1582
+
1583
+ // src/hbridge/cli.mjs
1584
+ import { networkInterfaces } from "os";
1585
+ var HBRIDGE_VERSION = "v177.0.c21a99a";
1586
+ var server = null;
1587
+ var statusBarInterval = null;
1588
+ function getLocalIPs() {
1589
+ return Object.values(networkInterfaces()).flat().filter((n) => n.family === "IPv4" && !n.internal).map((n) => n.address);
1590
+ }
1591
+ async function cmd_enable() {
1592
+ const cwd = process.cwd();
1593
+ const port = homePort(cwd);
1594
+ const key = homeKey(cwd);
1595
+ const ips = getLocalIPs();
1596
+ const ip = isHome() ? "127.0.0.1" : ips[0] || "127.0.0.1";
1597
+ console.log("hbridge enabled");
1598
+ console.log("\u{1F4C2} " + cwd);
1599
+ console.log("\u{1F511} " + ip + ":" + port + " | " + key + " | " + HBRIDGE_VERSION);
1600
+ server = createServer(key);
1601
+ server.on("error", (err) => {
1602
+ if (err.code === "EADDRINUSE") {
1603
+ process.stderr.write(`Port ${port} already in use \u2014 hbridge may already be running
1604
+ `);
1605
+ } else {
1606
+ process.stderr.write(`Server error: ${err.message}
1607
+ `);
1608
+ }
1609
+ });
1610
+ server.listen(port, isHome() ? "127.0.0.1" : void 0);
1611
+ markRunning(port);
1612
+ statusBarInterval = startStatusBar(port);
1613
+ process.stdin.resume();
1614
+ }
1615
+ function cmd_disable() {
1616
+ if (server) {
1617
+ server.close(() => {
1618
+ server = null;
1619
+ markStopped();
1620
+ console.log(" hbridge: off");
1621
+ stopStatusBar(statusBarInterval);
1622
+ process.exit(0);
1623
+ });
1624
+ setTimeout(() => process.exit(0), 1e3);
1625
+ } else {
1626
+ markStopped();
1627
+ console.log(" hbridge: off");
1628
+ process.exit(0);
1629
+ }
1630
+ }
1631
+ function cmd_status() {
1632
+ const cwd = process.cwd();
1633
+ const port = homePort(cwd);
1634
+ const key = homeKey(cwd);
1635
+ const ips = getLocalIPs();
1636
+ const state = readState();
1637
+ const ip = isHome() ? "127.0.0.1" : ips[0] || "127.0.0.1";
1638
+ const running = state.running;
1639
+ console.log(running ? "hbridge enabled" : "hbridge stopped");
1640
+ console.log("\u{1F4C2} " + cwd);
1641
+ console.log("\u{1F511} " + ip + ":" + port + " | " + key + " | " + HBRIDGE_VERSION);
1642
+ }
1643
+ function showHelp() {
1644
+ console.log(`
1645
+ hbridge \u2014 Hermes Bridge
1646
+
1647
+ COMMANDS:
1648
+ hbridge --enable Start bridge with deterministic key
1649
+ hbridge --disable Stop bridge
1650
+ hbridge --status Show status + last client connection
1651
+ hbridge --help Show this help
1652
+
1653
+ Port is derived from the working directory; key is machine-global from ~/.hbridge_key.
1654
+ Home mode (HBRIDGE_HOME=1) skips auth; remote mode enforces key.
1655
+
1656
+ EXAMPLES:
1657
+ hbridge --enable Enable with dir-derived key
1658
+ hbridge --status Show connected IP + last active time
1659
+ `);
1660
+ process.exit(0);
1661
+ }
1662
+ var args = process.argv.slice(2);
1663
+ var cmd = args[0];
1664
+ if (isHome()) {
1665
+ cmd_enable();
1666
+ } else if (cmd === "--enable") cmd_enable();
1667
+ else if (cmd === "--disable") cmd_disable();
1668
+ else if (cmd === "--status") cmd_status();
1669
+ else if (cmd === "--help" || cmd === "-h") {
1670
+ showHelp();
1671
+ } else {
1672
+ console.log("hbridge \u2014 Hermes Bridge");
1673
+ console.log(" hbridge --enable Start bridge");
1674
+ console.log(" hbridge --disable Stop bridge");
1675
+ console.log(" hbridge --status Show status");
1676
+ console.log(" hbridge --help Show help");
1677
+ }