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.
- package/DESIGN.md +233 -0
- package/LICENSE +21 -0
- package/README.md +236 -0
- package/build.mjs +35 -0
- package/dist/hbridge.mjs +1677 -0
- package/dist/statusline.mjs +50 -0
- package/docs/local-mode.md +67 -0
- package/docs/mcp-spec.md +85 -0
- package/docs/spawn-mechanism.md +96 -0
- package/package.json +29 -0
- package/scripts/setup-mcp.cjs +53 -0
package/dist/hbridge.mjs
ADDED
|
@@ -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
|
+
}
|