agent-ws 1.0.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/dist/cli.js ADDED
@@ -0,0 +1,855 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/server/websocket.ts
7
+ import { WebSocketServer, WebSocket } from "ws";
8
+
9
+ // src/process/claude-runner.ts
10
+ import { spawn } from "node:child_process";
11
+ import { mkdirSync } from "node:fs";
12
+ import { resolve } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+ import { createInterface } from "node:readline";
15
+ var DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
16
+ var ClaudeRunner = class {
17
+ process = null;
18
+ timeout = null;
19
+ disposed = false;
20
+ killed = false;
21
+ claudePath;
22
+ timeoutMs;
23
+ log;
24
+ sessionDir;
25
+ constructor(options) {
26
+ this.claudePath = options.claudePath ?? "claude";
27
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
28
+ this.log = options.logger;
29
+ this.sessionDir = options.sessionDir ?? "agent-ws-sessions";
30
+ }
31
+ get isRunning() {
32
+ return this.process !== null;
33
+ }
34
+ run(options, handlers) {
35
+ if (this.disposed) {
36
+ handlers.onError("Runner has been disposed", options.requestId);
37
+ return;
38
+ }
39
+ this.kill();
40
+ const { prompt, model, systemPrompt, projectId, requestId, thinkingTokens } = options;
41
+ const args = [
42
+ "--print",
43
+ "--output-format",
44
+ "stream-json",
45
+ "--max-turns",
46
+ "1",
47
+ // Single-turn text output, no agentic loops
48
+ "--tools",
49
+ ""
50
+ // Disable tool use — we only want generated text
51
+ ];
52
+ if (projectId) {
53
+ args.push("--continue");
54
+ }
55
+ if (model) {
56
+ args.push("--model", model);
57
+ }
58
+ if (systemPrompt) {
59
+ args.push("--append-system-prompt", systemPrompt);
60
+ }
61
+ args.push("-");
62
+ this.log.info({ requestId, model, promptLength: prompt.length }, "Spawning Claude process");
63
+ this.killed = false;
64
+ let cwd;
65
+ if (projectId) {
66
+ const base = resolve(tmpdir(), this.sessionDir);
67
+ cwd = resolve(base, projectId);
68
+ if (!cwd.startsWith(base + "/") && cwd !== base) {
69
+ handlers.onError("Invalid projectId", requestId);
70
+ return;
71
+ }
72
+ mkdirSync(cwd, { recursive: true });
73
+ }
74
+ try {
75
+ const ALLOWED_ENV_KEYS = [
76
+ "PATH",
77
+ "HOME",
78
+ "USER",
79
+ "SHELL",
80
+ "TERM",
81
+ "LANG",
82
+ "LC_ALL",
83
+ "ANTHROPIC_API_KEY",
84
+ "NODE_PATH",
85
+ "XDG_CONFIG_HOME"
86
+ ];
87
+ const env = {};
88
+ if (thinkingTokens !== void 0) {
89
+ env["MAX_THINKING_TOKENS"] = String(thinkingTokens);
90
+ }
91
+ for (const key of ALLOWED_ENV_KEYS) {
92
+ if (process.env[key]) env[key] = process.env[key];
93
+ }
94
+ this.process = spawn(this.claudePath, args, {
95
+ stdio: ["pipe", "pipe", "pipe"],
96
+ cwd,
97
+ env
98
+ });
99
+ } catch (err) {
100
+ const message = err instanceof Error ? err.message : "Failed to start Claude";
101
+ this.log.error({ err, requestId }, "Failed to spawn Claude process");
102
+ handlers.onError(message, requestId);
103
+ return;
104
+ }
105
+ this.log.debug({ pid: this.process.pid, requestId }, "Claude process spawned");
106
+ if (this.process.stdin) {
107
+ this.process.stdin.write(prompt);
108
+ this.process.stdin.end();
109
+ }
110
+ let handlersDone = false;
111
+ const finish = (cb) => {
112
+ if (handlersDone) return;
113
+ handlersDone = true;
114
+ this.clearTimeout();
115
+ cb();
116
+ };
117
+ this.timeout = setTimeout(() => {
118
+ this.log.warn({ requestId }, "Claude process timed out");
119
+ this.kill();
120
+ finish(() => handlers.onError("Process timed out", requestId));
121
+ }, this.timeoutMs);
122
+ if (this.process.stdout) {
123
+ const rl = createInterface({ input: this.process.stdout });
124
+ rl.on("line", (line) => {
125
+ this.parseStreamLine(line, handlers, requestId);
126
+ });
127
+ }
128
+ if (this.process.stderr) {
129
+ const stderrRl = createInterface({ input: this.process.stderr });
130
+ stderrRl.on("line", (line) => {
131
+ if (line.trim()) {
132
+ this.log.warn({ requestId, stderr: line }, "Claude stderr");
133
+ }
134
+ });
135
+ }
136
+ this.process.on("exit", (exitCode, signal) => {
137
+ this.process = null;
138
+ if (this.killed) {
139
+ this.log.debug({ requestId }, "Claude process was killed");
140
+ return;
141
+ }
142
+ if (exitCode === 0) {
143
+ this.log.info({ requestId }, "Claude process completed successfully");
144
+ finish(() => handlers.onComplete(requestId));
145
+ } else {
146
+ const reason = exitCode !== null ? `Claude CLI exited with code ${exitCode}` : `Claude CLI killed by signal ${signal ?? "unknown"}`;
147
+ this.log.warn({ requestId, exitCode, signal }, reason);
148
+ finish(() => handlers.onError(reason, requestId));
149
+ }
150
+ });
151
+ this.process.on("error", (err) => {
152
+ this.process = null;
153
+ this.log.error({ err, requestId }, "Claude process error");
154
+ finish(() => handlers.onError(err.message, requestId));
155
+ });
156
+ }
157
+ /**
158
+ * Parse a single NDJSON line from Claude CLI's stream-json output.
159
+ *
160
+ * The stream-json format can emit several event types. We look for content
161
+ * in these known patterns (in priority order):
162
+ *
163
+ * 1. Raw Anthropic API event: { type: "content_block_delta", delta: { type: "text_delta"|"thinking_delta", text|thinking } }
164
+ * 2. Wrapped stream event: { type: "stream_event", event: { type: "content_block_delta", ... } }
165
+ * 3. Complete assistant msg: { type: "assistant", message: { content: [{ type: "text"|"thinking", text|thinking }] } }
166
+ */
167
+ parseStreamLine(line, handlers, requestId) {
168
+ if (!line.trim()) return;
169
+ try {
170
+ const event = JSON.parse(line);
171
+ if (event.type === "content_block_delta") {
172
+ if (event.delta?.type === "text_delta" && event.delta.text) {
173
+ handlers.onChunk(event.delta.text, requestId);
174
+ } else if (event.delta?.type === "thinking_delta" && event.delta.thinking) {
175
+ handlers.onChunk(event.delta.thinking, requestId, true);
176
+ }
177
+ return;
178
+ }
179
+ if (event.type === "stream_event" && event.event) {
180
+ const inner = event.event;
181
+ if (inner.type === "content_block_delta") {
182
+ if (inner.delta?.type === "text_delta" && inner.delta.text) {
183
+ handlers.onChunk(inner.delta.text, requestId);
184
+ } else if (inner.delta?.type === "thinking_delta" && inner.delta.thinking) {
185
+ handlers.onChunk(inner.delta.thinking, requestId, true);
186
+ }
187
+ }
188
+ return;
189
+ }
190
+ if (event.type === "assistant" && Array.isArray(event.message?.content)) {
191
+ for (const block of event.message.content) {
192
+ if (block.type === "text" && block.text) {
193
+ handlers.onChunk(block.text, requestId);
194
+ } else if (block.type === "thinking" && block.thinking) {
195
+ handlers.onChunk(block.thinking, requestId, true);
196
+ }
197
+ }
198
+ return;
199
+ }
200
+ if (event.type === "result") {
201
+ return;
202
+ }
203
+ } catch {
204
+ }
205
+ }
206
+ kill() {
207
+ this.clearTimeout();
208
+ if (this.process) {
209
+ this.log.debug({ pid: this.process.pid }, "Killing Claude process");
210
+ this.killed = true;
211
+ try {
212
+ this.process.kill();
213
+ } catch {
214
+ }
215
+ this.process = null;
216
+ }
217
+ }
218
+ dispose() {
219
+ this.disposed = true;
220
+ this.kill();
221
+ }
222
+ clearTimeout() {
223
+ if (this.timeout) {
224
+ clearTimeout(this.timeout);
225
+ this.timeout = null;
226
+ }
227
+ }
228
+ };
229
+
230
+ // src/process/codex-runner.ts
231
+ import { spawn as spawn2 } from "node:child_process";
232
+ import { mkdirSync as mkdirSync2 } from "node:fs";
233
+ import { resolve as resolve2 } from "node:path";
234
+ import { tmpdir as tmpdir2 } from "node:os";
235
+ import { createInterface as createInterface2 } from "node:readline";
236
+ var DEFAULT_TIMEOUT_MS2 = 5 * 60 * 1e3;
237
+ var CodexRunner = class {
238
+ process = null;
239
+ timeout = null;
240
+ disposed = false;
241
+ killed = false;
242
+ codexPath;
243
+ timeoutMs;
244
+ log;
245
+ sessionDir;
246
+ constructor(options) {
247
+ this.codexPath = options.codexPath ?? "codex";
248
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
249
+ this.log = options.logger;
250
+ this.sessionDir = options.sessionDir ?? "agent-ws-sessions";
251
+ }
252
+ get isRunning() {
253
+ return this.process !== null;
254
+ }
255
+ run(options, handlers) {
256
+ if (this.disposed) {
257
+ handlers.onError("Runner has been disposed", options.requestId);
258
+ return;
259
+ }
260
+ this.kill();
261
+ const { prompt, model, systemPrompt, projectId, requestId } = options;
262
+ let fullPrompt = prompt;
263
+ if (systemPrompt) {
264
+ fullPrompt = `${systemPrompt}
265
+
266
+ ---
267
+
268
+ ${prompt}`;
269
+ }
270
+ const args = ["--json"];
271
+ if (model) {
272
+ args.push("--model", model);
273
+ }
274
+ args.push("-");
275
+ this.log.info({ requestId, model, promptLength: prompt.length }, "Spawning Codex process");
276
+ this.killed = false;
277
+ let cwd;
278
+ if (projectId) {
279
+ const base = resolve2(tmpdir2(), this.sessionDir);
280
+ cwd = resolve2(base, projectId);
281
+ if (!cwd.startsWith(base + "/") && cwd !== base) {
282
+ handlers.onError("Invalid projectId", requestId);
283
+ return;
284
+ }
285
+ mkdirSync2(cwd, { recursive: true });
286
+ }
287
+ const ALLOWED_ENV_KEYS = [
288
+ "PATH",
289
+ "HOME",
290
+ "USER",
291
+ "SHELL",
292
+ "TERM",
293
+ "LANG",
294
+ "LC_ALL",
295
+ "OPENAI_API_KEY",
296
+ "NODE_PATH",
297
+ "XDG_CONFIG_HOME"
298
+ ];
299
+ const env = {};
300
+ for (const key of ALLOWED_ENV_KEYS) {
301
+ if (process.env[key]) env[key] = process.env[key];
302
+ }
303
+ try {
304
+ this.process = spawn2(this.codexPath, args, {
305
+ stdio: ["pipe", "pipe", "pipe"],
306
+ cwd,
307
+ env
308
+ });
309
+ } catch (err) {
310
+ const message = err instanceof Error ? err.message : "Failed to start Codex";
311
+ this.log.error({ err, requestId }, "Failed to spawn Codex process");
312
+ handlers.onError(message, requestId);
313
+ return;
314
+ }
315
+ this.log.debug({ pid: this.process.pid, requestId }, "Codex process spawned");
316
+ if (this.process.stdin) {
317
+ this.process.stdin.write(fullPrompt);
318
+ this.process.stdin.end();
319
+ }
320
+ let handlersDone = false;
321
+ const finish = (cb) => {
322
+ if (handlersDone) return;
323
+ handlersDone = true;
324
+ this.clearTimeout();
325
+ cb();
326
+ };
327
+ this.timeout = setTimeout(() => {
328
+ this.log.warn({ requestId }, "Codex process timed out");
329
+ this.kill();
330
+ finish(() => handlers.onError("Process timed out", requestId));
331
+ }, this.timeoutMs);
332
+ if (this.process.stdout) {
333
+ const rl = createInterface2({ input: this.process.stdout });
334
+ rl.on("line", (line) => {
335
+ this.parseStreamLine(line, handlers, requestId);
336
+ });
337
+ }
338
+ if (this.process.stderr) {
339
+ const stderrRl = createInterface2({ input: this.process.stderr });
340
+ stderrRl.on("line", (line) => {
341
+ if (line.trim()) {
342
+ this.log.warn({ requestId, stderr: line }, "Codex stderr");
343
+ }
344
+ });
345
+ }
346
+ this.process.on("exit", (exitCode, signal) => {
347
+ this.process = null;
348
+ if (this.killed) {
349
+ this.log.debug({ requestId }, "Codex process was killed");
350
+ return;
351
+ }
352
+ if (exitCode === 0) {
353
+ this.log.info({ requestId }, "Codex process completed successfully");
354
+ finish(() => handlers.onComplete(requestId));
355
+ } else {
356
+ const reason = exitCode !== null ? `Codex CLI exited with code ${exitCode}` : `Codex CLI killed by signal ${signal ?? "unknown"}`;
357
+ this.log.warn({ requestId, exitCode, signal }, reason);
358
+ finish(() => handlers.onError(reason, requestId));
359
+ }
360
+ });
361
+ this.process.on("error", (err) => {
362
+ this.process = null;
363
+ this.log.error({ err, requestId }, "Codex process error");
364
+ finish(() => handlers.onError(err.message, requestId));
365
+ });
366
+ }
367
+ /**
368
+ * Parse JSONL output from Codex CLI.
369
+ * Looks for text content in response events.
370
+ */
371
+ parseStreamLine(line, handlers, requestId) {
372
+ if (!line.trim()) return;
373
+ try {
374
+ const event = JSON.parse(line);
375
+ if (event.type === "message" && event.role === "assistant") {
376
+ if (Array.isArray(event.content)) {
377
+ for (const block of event.content) {
378
+ if (block.type === "output_text" && block.text) {
379
+ handlers.onChunk(block.text, requestId);
380
+ } else if (block.type === "text" && block.text) {
381
+ handlers.onChunk(block.text, requestId);
382
+ }
383
+ }
384
+ } else if (typeof event.content === "string" && event.content) {
385
+ handlers.onChunk(event.content, requestId);
386
+ }
387
+ return;
388
+ }
389
+ if (event.type === "response.completed" || event.type === "item.completed") {
390
+ return;
391
+ }
392
+ } catch {
393
+ }
394
+ }
395
+ kill() {
396
+ this.clearTimeout();
397
+ if (this.process) {
398
+ this.log.debug({ pid: this.process.pid }, "Killing Codex process");
399
+ this.killed = true;
400
+ try {
401
+ this.process.kill();
402
+ } catch {
403
+ }
404
+ this.process = null;
405
+ }
406
+ }
407
+ dispose() {
408
+ this.disposed = true;
409
+ this.kill();
410
+ }
411
+ clearTimeout() {
412
+ if (this.timeout) {
413
+ clearTimeout(this.timeout);
414
+ this.timeout = null;
415
+ }
416
+ }
417
+ };
418
+
419
+ // src/server/protocol.ts
420
+ var MAX_PROMPT_BYTES = 512 * 1024;
421
+ var MAX_SYSTEM_PROMPT_BYTES = 64 * 1024;
422
+ var MAX_PROJECT_ID_LENGTH = 128;
423
+ var PROJECT_ID_PATTERN = /^[a-zA-Z0-9._-]+$/;
424
+ function parseClientMessage(raw) {
425
+ let data;
426
+ try {
427
+ data = JSON.parse(raw);
428
+ } catch {
429
+ return { ok: false, error: "Invalid JSON" };
430
+ }
431
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
432
+ return { ok: false, error: "Message must be a JSON object" };
433
+ }
434
+ const obj = data;
435
+ const type = obj["type"];
436
+ if (typeof type !== "string") {
437
+ return { ok: false, error: "Missing or invalid 'type' field" };
438
+ }
439
+ switch (type) {
440
+ case "prompt": {
441
+ const prompt = obj["prompt"];
442
+ if (typeof prompt !== "string" || prompt.length === 0) {
443
+ return { ok: false, error: "Missing or empty 'prompt' field" };
444
+ }
445
+ if (new TextEncoder().encode(prompt).byteLength > MAX_PROMPT_BYTES) {
446
+ return { ok: false, error: `Prompt exceeds maximum size of ${MAX_PROMPT_BYTES} bytes` };
447
+ }
448
+ const requestId = obj["requestId"];
449
+ if (typeof requestId !== "string" || requestId.length === 0) {
450
+ return { ok: false, error: "Missing or empty 'requestId' field" };
451
+ }
452
+ const model = obj["model"];
453
+ const systemPrompt = obj["systemPrompt"];
454
+ const projectId = obj["projectId"];
455
+ const provider = obj["provider"];
456
+ const thinkingTokens = obj["thinkingTokens"];
457
+ if (typeof systemPrompt === "string" && new TextEncoder().encode(systemPrompt).byteLength > MAX_SYSTEM_PROMPT_BYTES) {
458
+ return { ok: false, error: `System prompt exceeds maximum size of ${MAX_SYSTEM_PROMPT_BYTES} bytes` };
459
+ }
460
+ if (typeof projectId === "string") {
461
+ if (projectId.length > MAX_PROJECT_ID_LENGTH) {
462
+ return { ok: false, error: `projectId exceeds maximum length of ${MAX_PROJECT_ID_LENGTH}` };
463
+ }
464
+ if (!PROJECT_ID_PATTERN.test(projectId)) {
465
+ return { ok: false, error: "projectId contains invalid characters (allowed: alphanumeric, hyphens, underscores, dots)" };
466
+ }
467
+ }
468
+ return {
469
+ ok: true,
470
+ message: {
471
+ type: "prompt",
472
+ prompt,
473
+ model: typeof model === "string" ? model : void 0,
474
+ systemPrompt: typeof systemPrompt === "string" ? systemPrompt : void 0,
475
+ projectId: typeof projectId === "string" ? projectId : void 0,
476
+ requestId,
477
+ provider: provider === "codex" ? "codex" : "claude",
478
+ thinkingTokens: typeof thinkingTokens === "number" && thinkingTokens >= 0 ? thinkingTokens : void 0
479
+ }
480
+ };
481
+ }
482
+ case "cancel": {
483
+ const requestId = obj["requestId"];
484
+ return {
485
+ ok: true,
486
+ message: {
487
+ type: "cancel",
488
+ requestId: typeof requestId === "string" ? requestId : void 0
489
+ }
490
+ };
491
+ }
492
+ default:
493
+ return { ok: false, error: `Unknown message type: ${String(type).slice(0, 50)}` };
494
+ }
495
+ }
496
+ function serializeMessage(message) {
497
+ return JSON.stringify(message);
498
+ }
499
+
500
+ // src/server/websocket.ts
501
+ var HEARTBEAT_INTERVAL_MS = 3e4;
502
+ var DEFAULT_MAX_PAYLOAD = 1024 * 1024;
503
+ var AgentWebSocketServer = class {
504
+ wss = null;
505
+ heartbeatInterval = null;
506
+ connections = /* @__PURE__ */ new Map();
507
+ log;
508
+ options;
509
+ constructor(options) {
510
+ this.options = options;
511
+ this.log = options.logger;
512
+ }
513
+ start() {
514
+ return new Promise((resolve3, reject) => {
515
+ this.wss = new WebSocketServer({
516
+ port: this.options.port,
517
+ host: this.options.host,
518
+ maxPayload: this.options.maxPayload ?? DEFAULT_MAX_PAYLOAD
519
+ });
520
+ this.wss.on("listening", () => {
521
+ this.log.info({ port: this.options.port, host: this.options.host }, "WebSocket server started");
522
+ this.startHeartbeat();
523
+ resolve3();
524
+ });
525
+ this.wss.on("error", (err) => {
526
+ if (err.code === "EADDRINUSE") {
527
+ this.log.fatal({ port: this.options.port }, "Port already in use");
528
+ } else {
529
+ this.log.error({ err }, "WebSocket server error");
530
+ }
531
+ reject(err);
532
+ });
533
+ this.wss.on("connection", (ws, req) => this.handleConnection(ws, req));
534
+ });
535
+ }
536
+ stop() {
537
+ if (this.heartbeatInterval) {
538
+ clearInterval(this.heartbeatInterval);
539
+ this.heartbeatInterval = null;
540
+ }
541
+ for (const [ws, state] of this.connections) {
542
+ state.runner.dispose();
543
+ ws.terminate();
544
+ }
545
+ this.connections.clear();
546
+ if (this.wss) {
547
+ this.wss.close();
548
+ this.wss = null;
549
+ }
550
+ this.log.info("WebSocket server stopped");
551
+ }
552
+ handleConnection(ws, req) {
553
+ if (this.options.allowedOrigins && this.options.allowedOrigins.length > 0) {
554
+ const origin = req.headers.origin;
555
+ if (!origin || !this.options.allowedOrigins.includes(origin)) {
556
+ this.log.warn({ origin: origin ?? "(none)" }, "Rejected connection: origin not in allowlist");
557
+ ws.close(4003, "Origin not allowed");
558
+ return;
559
+ }
560
+ }
561
+ const clientIp = req.socket.remoteAddress;
562
+ this.log.info({ clientIp }, "Client connected");
563
+ const runner = this.createRunner();
564
+ const state = { runner, isAlive: true, activeRequestId: null };
565
+ this.connections.set(ws, state);
566
+ this.sendMessage(ws, {
567
+ type: "connected",
568
+ version: "1.0",
569
+ agent: this.options.agentName ?? "agent-ws"
570
+ });
571
+ ws.on("pong", () => {
572
+ state.isAlive = true;
573
+ });
574
+ ws.on("message", (data) => {
575
+ const raw = data.toString();
576
+ this.handleMessage(ws, state, raw);
577
+ });
578
+ ws.on("close", () => {
579
+ this.log.info({ clientIp }, "Client disconnected");
580
+ state.runner.dispose();
581
+ this.connections.delete(ws);
582
+ });
583
+ ws.on("error", (err) => {
584
+ this.log.error({ err, clientIp }, "WebSocket error");
585
+ });
586
+ }
587
+ handleMessage(ws, state, raw) {
588
+ const result = parseClientMessage(raw);
589
+ if (!result.ok) {
590
+ this.sendMessage(ws, { type: "error", message: result.error });
591
+ return;
592
+ }
593
+ const { message } = result;
594
+ switch (message.type) {
595
+ case "prompt":
596
+ this.handlePrompt(ws, state, message);
597
+ break;
598
+ case "cancel":
599
+ this.handleCancel(ws, state);
600
+ break;
601
+ }
602
+ }
603
+ handlePrompt(ws, state, message) {
604
+ if (state.activeRequestId !== null) {
605
+ this.sendMessage(ws, {
606
+ type: "error",
607
+ message: "Request already in progress",
608
+ requestId: message.requestId
609
+ });
610
+ return;
611
+ }
612
+ state.activeRequestId = message.requestId;
613
+ if (message.provider === "codex") {
614
+ if (!(state.runner instanceof CodexRunner)) {
615
+ try {
616
+ state.runner.dispose();
617
+ } catch {
618
+ }
619
+ state.runner = new CodexRunner({
620
+ timeoutMs: this.options.timeoutMs,
621
+ logger: this.log.child({ component: "codex-runner" }),
622
+ sessionDir: this.options.sessionDir
623
+ });
624
+ }
625
+ } else {
626
+ if (!(state.runner instanceof ClaudeRunner)) {
627
+ try {
628
+ state.runner.dispose();
629
+ } catch {
630
+ }
631
+ state.runner = this.createRunner();
632
+ }
633
+ }
634
+ const handlers = {
635
+ onChunk: (content, requestId, thinking) => {
636
+ try {
637
+ this.sendMessage(ws, { type: "chunk", content, requestId, ...thinking ? { thinking: true } : {} });
638
+ } catch (err) {
639
+ this.log.warn({ err, requestId }, "Error in onChunk handler");
640
+ }
641
+ },
642
+ onComplete: (requestId) => {
643
+ try {
644
+ state.activeRequestId = null;
645
+ this.sendMessage(ws, { type: "complete", requestId });
646
+ } catch (err) {
647
+ this.log.warn({ err, requestId }, "Error in onComplete handler");
648
+ }
649
+ },
650
+ onError: (errorMessage, requestId) => {
651
+ try {
652
+ state.activeRequestId = null;
653
+ this.sendMessage(ws, { type: "error", message: errorMessage, requestId });
654
+ } catch (err) {
655
+ this.log.warn({ err, requestId }, "Error in onError handler");
656
+ }
657
+ }
658
+ };
659
+ state.runner.run(
660
+ { prompt: message.prompt, model: message.model, systemPrompt: message.systemPrompt, projectId: message.projectId, requestId: message.requestId, thinkingTokens: message.thinkingTokens },
661
+ handlers
662
+ );
663
+ }
664
+ handleCancel(ws, state) {
665
+ state.runner.kill();
666
+ const requestId = state.activeRequestId;
667
+ state.activeRequestId = null;
668
+ this.log.info({ requestId }, "Request cancelled");
669
+ }
670
+ sendMessage(ws, message) {
671
+ if (ws.readyState === WebSocket.OPEN) {
672
+ ws.send(serializeMessage(message));
673
+ } else {
674
+ this.log.warn({ messageType: message.type, readyState: ws.readyState }, "Dropping message, WebSocket not OPEN");
675
+ }
676
+ }
677
+ createRunner() {
678
+ if (this.options.runnerFactory) {
679
+ return this.options.runnerFactory(this.log);
680
+ }
681
+ const runnerOptions = {
682
+ claudePath: this.options.claudePath,
683
+ timeoutMs: this.options.timeoutMs,
684
+ logger: this.log.child({ component: "runner" }),
685
+ sessionDir: this.options.sessionDir
686
+ };
687
+ return new ClaudeRunner(runnerOptions);
688
+ }
689
+ startHeartbeat() {
690
+ this.heartbeatInterval = setInterval(() => {
691
+ for (const [ws, state] of this.connections) {
692
+ if (!state.isAlive) {
693
+ this.log.debug("Terminating dead connection");
694
+ state.runner.dispose();
695
+ this.connections.delete(ws);
696
+ ws.terminate();
697
+ continue;
698
+ }
699
+ state.isAlive = false;
700
+ try {
701
+ ws.ping();
702
+ } catch {
703
+ this.log.debug("Ping failed, terminating connection");
704
+ state.runner.dispose();
705
+ this.connections.delete(ws);
706
+ ws.terminate();
707
+ }
708
+ }
709
+ }, HEARTBEAT_INTERVAL_MS);
710
+ }
711
+ };
712
+
713
+ // src/utils/logger.ts
714
+ import pino from "pino";
715
+ function createLogger(options = {}) {
716
+ const { level = "info", pretty = process.env["NODE_ENV"] !== "production" } = options;
717
+ if (pretty) {
718
+ try {
719
+ return pino({
720
+ level,
721
+ transport: {
722
+ target: "pino-pretty",
723
+ options: {
724
+ colorize: true,
725
+ translateTime: "HH:MM:ss",
726
+ ignore: "pid,hostname"
727
+ }
728
+ }
729
+ });
730
+ } catch {
731
+ }
732
+ }
733
+ return pino({ level });
734
+ }
735
+
736
+ // src/agent.ts
737
+ var AgentWS = class {
738
+ server;
739
+ log;
740
+ constructor(options = {}) {
741
+ this.log = createLogger({ level: options.logLevel ?? "info" });
742
+ const serverOptions = {
743
+ port: options.port ?? 9999,
744
+ host: options.host ?? "localhost",
745
+ logger: this.log,
746
+ claudePath: options.claudePath,
747
+ timeoutMs: options.timeoutMs,
748
+ allowedOrigins: options.allowedOrigins,
749
+ runnerFactory: options.runnerFactory,
750
+ agentName: options.agentName,
751
+ sessionDir: options.sessionDir
752
+ };
753
+ this.server = new AgentWebSocketServer(serverOptions);
754
+ }
755
+ async start() {
756
+ await this.server.start();
757
+ }
758
+ stop() {
759
+ this.server.stop();
760
+ }
761
+ };
762
+
763
+ // src/utils/claude-check.ts
764
+ import { execFileSync } from "node:child_process";
765
+ function checkClaudeCli(claudePath = "claude") {
766
+ try {
767
+ const output = execFileSync(claudePath, ["--version"], {
768
+ timeout: 5e3,
769
+ stdio: "pipe",
770
+ encoding: "utf-8"
771
+ });
772
+ return {
773
+ available: true,
774
+ version: output.trim()
775
+ };
776
+ } catch (err) {
777
+ const message = err instanceof Error ? err.message : "Unknown error";
778
+ return {
779
+ available: false,
780
+ error: message
781
+ };
782
+ }
783
+ }
784
+
785
+ // src/cli.ts
786
+ var VERSION = true ? "1.0.0" : "0.0.0-dev";
787
+ var program = new Command();
788
+ program.name("agent-ws").description("WebSocket bridge for CLI AI agents (Claude, Codex)").version(VERSION).option("-p, --port <port>", "WebSocket server port", "9999").option("-H, --host <host>", "WebSocket server host", "localhost").option("-c, --claude-path <path>", "Path to Claude CLI", "claude").option("-t, --timeout <seconds>", "Process timeout in seconds", "300").option("--log-level <level>", "Log level (debug, info, warn, error)", "info").option("--origins <origins>", "Comma-separated allowed origins").action(async (opts) => {
789
+ console.log(`
790
+ \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
791
+ \u2551 agent-ws v${VERSION.padEnd(20)}\u2551
792
+ \u2551 CLI AI Agent Bridge \u2551
793
+ \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
794
+ `);
795
+ const check = checkClaudeCli(opts.claudePath);
796
+ if (!check.available) {
797
+ console.error(`Claude CLI not found at: ${opts.claudePath}`);
798
+ console.error("Make sure Claude Code is installed and in your PATH.");
799
+ console.error("Install: npm install -g @anthropic-ai/claude-code");
800
+ console.error(`Or specify path: agent-ws --claude-path /path/to/claude`);
801
+ process.exit(1);
802
+ }
803
+ console.log(`Found Claude CLI: ${check.version}`);
804
+ const port = parseInt(opts.port, 10);
805
+ if (isNaN(port) || port < 1 || port > 65535) {
806
+ console.error(`Invalid port: ${opts.port} (must be 1\u201365535)`);
807
+ process.exit(1);
808
+ }
809
+ const timeoutSeconds = parseInt(opts.timeout, 10);
810
+ if (isNaN(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 3600) {
811
+ console.error(`Invalid timeout: ${opts.timeout} (must be 1\u20133600 seconds)`);
812
+ process.exit(1);
813
+ }
814
+ const timeoutMs = timeoutSeconds * 1e3;
815
+ const allowedOrigins = opts.origins?.split(",").map((o) => o.trim()).filter(Boolean);
816
+ if (allowedOrigins) {
817
+ for (const origin of allowedOrigins) {
818
+ try {
819
+ new URL(origin);
820
+ } catch {
821
+ console.error(`Invalid origin: "${origin}" (must be a valid URL, e.g. https://example.com)`);
822
+ process.exit(1);
823
+ }
824
+ }
825
+ }
826
+ const agent = new AgentWS({
827
+ port,
828
+ host: opts.host,
829
+ claudePath: opts.claudePath,
830
+ timeoutMs,
831
+ logLevel: opts.logLevel,
832
+ allowedOrigins
833
+ });
834
+ try {
835
+ await agent.start();
836
+ } catch (err) {
837
+ if (err instanceof Error && "code" in err && err.code === "EADDRINUSE") {
838
+ console.error(`Port ${port} is already in use.`);
839
+ console.error("Another instance of agent-ws might be running.");
840
+ }
841
+ process.exit(1);
842
+ }
843
+ console.log(`agent-ws running on ws://${opts.host}:${port}`);
844
+ console.log("Waiting for connections...\n");
845
+ console.log("Press Ctrl+C to stop\n");
846
+ const shutdown = () => {
847
+ agent.stop();
848
+ console.log("\nagent-ws stopped");
849
+ process.exit(0);
850
+ };
851
+ process.on("SIGINT", shutdown);
852
+ process.on("SIGTERM", shutdown);
853
+ });
854
+ program.parse();
855
+ //# sourceMappingURL=cli.js.map