@webmux/agent 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1046 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ AGENT_PACKAGE_NAME,
4
+ AGENT_VERSION,
5
+ upgradeService
6
+ } from "./chunk-INUNCXBM.js";
7
+ import {
8
+ assertValidSessionName
9
+ } from "./chunk-EWE7ZUYJ.js";
10
+
11
+ // src/connection.ts
12
+ import WebSocket from "ws";
13
+
14
+ // ../shared/src/contracts.ts
15
+ var DEFAULT_TERMINAL_SIZE = {
16
+ cols: 120,
17
+ rows: 36
18
+ };
19
+
20
+ // ../shared/src/version.ts
21
+ var SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
22
+ function parseSemanticVersion(version) {
23
+ const match = version.trim().match(SEMVER_PATTERN);
24
+ if (!match) {
25
+ return null;
26
+ }
27
+ return {
28
+ major: Number.parseInt(match[1], 10),
29
+ minor: Number.parseInt(match[2], 10),
30
+ patch: Number.parseInt(match[3], 10),
31
+ prerelease: match[4] ? match[4].split(".") : []
32
+ };
33
+ }
34
+ function compareSemanticVersions(left, right) {
35
+ const parsedLeft = parseSemanticVersion(left);
36
+ const parsedRight = parseSemanticVersion(right);
37
+ if (!parsedLeft || !parsedRight) {
38
+ throw new Error(`Invalid semantic version comparison: "${left}" vs "${right}"`);
39
+ }
40
+ if (parsedLeft.major !== parsedRight.major) {
41
+ return parsedLeft.major - parsedRight.major;
42
+ }
43
+ if (parsedLeft.minor !== parsedRight.minor) {
44
+ return parsedLeft.minor - parsedRight.minor;
45
+ }
46
+ if (parsedLeft.patch !== parsedRight.patch) {
47
+ return parsedLeft.patch - parsedRight.patch;
48
+ }
49
+ return comparePrerelease(parsedLeft.prerelease, parsedRight.prerelease);
50
+ }
51
+ function comparePrerelease(left, right) {
52
+ if (left.length === 0 && right.length === 0) {
53
+ return 0;
54
+ }
55
+ if (left.length === 0) {
56
+ return 1;
57
+ }
58
+ if (right.length === 0) {
59
+ return -1;
60
+ }
61
+ const maxLength = Math.max(left.length, right.length);
62
+ for (let index = 0; index < maxLength; index += 1) {
63
+ const leftIdentifier = left[index];
64
+ const rightIdentifier = right[index];
65
+ if (leftIdentifier === void 0) {
66
+ return -1;
67
+ }
68
+ if (rightIdentifier === void 0) {
69
+ return 1;
70
+ }
71
+ const numericLeft = Number.parseInt(leftIdentifier, 10);
72
+ const numericRight = Number.parseInt(rightIdentifier, 10);
73
+ const leftIsNumber = String(numericLeft) === leftIdentifier;
74
+ const rightIsNumber = String(numericRight) === rightIdentifier;
75
+ if (leftIsNumber && rightIsNumber && numericLeft !== numericRight) {
76
+ return numericLeft - numericRight;
77
+ }
78
+ if (leftIsNumber !== rightIsNumber) {
79
+ return leftIsNumber ? -1 : 1;
80
+ }
81
+ if (leftIdentifier !== rightIdentifier) {
82
+ return leftIdentifier < rightIdentifier ? -1 : 1;
83
+ }
84
+ }
85
+ return 0;
86
+ }
87
+
88
+ // src/terminal.ts
89
+ import { spawn } from "node-pty";
90
+ async function createTerminalBridge(options) {
91
+ const {
92
+ tmux,
93
+ sessionName,
94
+ cols = DEFAULT_TERMINAL_SIZE.cols,
95
+ rows = DEFAULT_TERMINAL_SIZE.rows,
96
+ onData,
97
+ onExit
98
+ } = options;
99
+ assertValidSessionName(sessionName);
100
+ await tmux.createSession(sessionName);
101
+ const ptyProcess = spawn(
102
+ "tmux",
103
+ ["-L", tmux.socketName, "attach-session", "-t", sessionName],
104
+ {
105
+ cols,
106
+ rows,
107
+ cwd: tmux.workspaceRoot,
108
+ env: {
109
+ ...process.env,
110
+ TERM: "xterm-256color"
111
+ },
112
+ name: "xterm-256color"
113
+ }
114
+ );
115
+ ptyProcess.onData(onData);
116
+ ptyProcess.onExit(({ exitCode }) => {
117
+ onExit(exitCode);
118
+ });
119
+ return {
120
+ write(data) {
121
+ ptyProcess.write(data);
122
+ },
123
+ resize(nextCols, nextRows) {
124
+ ptyProcess.resize(nextCols, nextRows);
125
+ },
126
+ dispose() {
127
+ ptyProcess.kill();
128
+ }
129
+ };
130
+ }
131
+
132
+ // src/run-wrapper.ts
133
+ import { spawn as spawn2 } from "node-pty";
134
+
135
+ // src/plain-output.ts
136
+ function joinOutputParts(parts) {
137
+ const output = parts.filter(Boolean).join("\n").replace(/\n{3,}/g, "\n\n").trim();
138
+ return output;
139
+ }
140
+ function parseCount(paramText, fallback) {
141
+ const value = Number.parseInt(paramText, 10);
142
+ return Number.isFinite(value) && value > 0 ? value : fallback;
143
+ }
144
+ var TerminalOutputSanitizer = class {
145
+ currentLine = "";
146
+ cursor = 0;
147
+ clearOnWrite = false;
148
+ completedLines = [];
149
+ push(rawText) {
150
+ for (let index = 0; index < rawText.length; index += 1) {
151
+ const char = rawText[index];
152
+ if (char === "\x1B" || char === "\x9B") {
153
+ index = this.handleEscapeSequence(rawText, index);
154
+ continue;
155
+ }
156
+ if (char === "\r") {
157
+ this.cursor = 0;
158
+ this.clearOnWrite = true;
159
+ continue;
160
+ }
161
+ if (char === "\n") {
162
+ this.commitLine();
163
+ continue;
164
+ }
165
+ if (char === "\b") {
166
+ this.cursor = Math.max(0, this.cursor - 1);
167
+ continue;
168
+ }
169
+ if (char === " ") {
170
+ this.writeChar(" ");
171
+ continue;
172
+ }
173
+ const code = char.charCodeAt(0);
174
+ if (code >= 0 && code <= 8 || code >= 11 && code <= 31 || code === 127) {
175
+ continue;
176
+ }
177
+ this.writeChar(char);
178
+ }
179
+ const lines = this.completedLines;
180
+ this.completedLines = [];
181
+ return joinOutputParts(lines);
182
+ }
183
+ flush() {
184
+ const line = this.normalizeLine(this.currentLine);
185
+ this.currentLine = "";
186
+ this.cursor = 0;
187
+ this.clearOnWrite = false;
188
+ return line;
189
+ }
190
+ handleEscapeSequence(text, startIndex) {
191
+ if (text[startIndex] === "\x9B") {
192
+ return this.handleCsiSequence(text, startIndex + 1);
193
+ }
194
+ const nextChar = text[startIndex + 1];
195
+ if (!nextChar) {
196
+ return startIndex;
197
+ }
198
+ if (nextChar === "[") {
199
+ return this.handleCsiSequence(text, startIndex + 2);
200
+ }
201
+ if (nextChar === "]") {
202
+ for (let index = startIndex + 2; index < text.length; index += 1) {
203
+ if (text[index] === "\x07") {
204
+ return index;
205
+ }
206
+ if (text[index] === "\x1B" && text[index + 1] === "\\") {
207
+ return index + 1;
208
+ }
209
+ }
210
+ return text.length - 1;
211
+ }
212
+ if (nextChar === "(" || nextChar === ")" || nextChar === "#") {
213
+ return Math.min(startIndex + 2, text.length - 1);
214
+ }
215
+ return startIndex + 1;
216
+ }
217
+ handleCsiSequence(text, cursorIndex) {
218
+ let paramText = "";
219
+ let index = cursorIndex;
220
+ while (index < text.length) {
221
+ const char = text[index];
222
+ const code = char.charCodeAt(0);
223
+ if (code >= 64 && code <= 126) {
224
+ this.applyCsiSequence(paramText, char);
225
+ return index;
226
+ }
227
+ paramText += char;
228
+ index += 1;
229
+ }
230
+ return text.length - 1;
231
+ }
232
+ applyCsiSequence(paramText, command) {
233
+ const normalizedParams = paramText.replace(/^\?/, "");
234
+ const parts = normalizedParams.length > 0 ? normalizedParams.split(";") : [];
235
+ switch (command) {
236
+ case "m":
237
+ return;
238
+ case "J":
239
+ if (parseCount(parts[0] ?? "", 0) === 2) {
240
+ this.currentLine = "";
241
+ this.cursor = 0;
242
+ this.clearOnWrite = false;
243
+ }
244
+ return;
245
+ case "K": {
246
+ const mode = parseCount(parts[0] ?? "", 0);
247
+ if (mode === 2) {
248
+ this.currentLine = "";
249
+ this.cursor = 0;
250
+ } else if (mode === 1) {
251
+ this.currentLine = this.currentLine.slice(this.cursor);
252
+ this.cursor = 0;
253
+ } else {
254
+ this.currentLine = this.currentLine.slice(0, this.cursor);
255
+ }
256
+ this.clearOnWrite = false;
257
+ return;
258
+ }
259
+ case "H":
260
+ case "f":
261
+ if (this.normalizeLine(this.currentLine)) {
262
+ this.commitLine();
263
+ } else {
264
+ this.currentLine = "";
265
+ this.cursor = 0;
266
+ this.clearOnWrite = false;
267
+ }
268
+ return;
269
+ case "G":
270
+ this.cursor = Math.max(0, parseCount(parts[0] ?? "", 1) - 1);
271
+ this.clearOnWrite = false;
272
+ return;
273
+ case "C":
274
+ this.cursor += parseCount(parts[0] ?? "", 1);
275
+ this.clearOnWrite = false;
276
+ return;
277
+ case "D":
278
+ this.cursor = Math.max(0, this.cursor - parseCount(parts[0] ?? "", 1));
279
+ this.clearOnWrite = false;
280
+ return;
281
+ case "P": {
282
+ const count = parseCount(parts[0] ?? "", 1);
283
+ this.currentLine = this.currentLine.slice(0, this.cursor) + this.currentLine.slice(this.cursor + count);
284
+ this.clearOnWrite = false;
285
+ return;
286
+ }
287
+ default:
288
+ return;
289
+ }
290
+ }
291
+ writeChar(char) {
292
+ if (this.clearOnWrite && this.cursor === 0) {
293
+ this.currentLine = "";
294
+ }
295
+ this.clearOnWrite = false;
296
+ if (this.cursor > this.currentLine.length) {
297
+ this.currentLine += " ".repeat(this.cursor - this.currentLine.length);
298
+ }
299
+ if (this.cursor === this.currentLine.length) {
300
+ this.currentLine += char;
301
+ } else {
302
+ this.currentLine = this.currentLine.slice(0, this.cursor) + char + this.currentLine.slice(this.cursor + 1);
303
+ }
304
+ this.cursor += 1;
305
+ }
306
+ commitLine() {
307
+ const line = this.normalizeLine(this.currentLine);
308
+ if (line) {
309
+ this.completedLines.push(line);
310
+ }
311
+ this.currentLine = "";
312
+ this.cursor = 0;
313
+ this.clearOnWrite = false;
314
+ }
315
+ normalizeLine(line) {
316
+ return line.replace(/[ \t]+$/g, "").trim();
317
+ }
318
+ };
319
+
320
+ // src/run-wrapper.ts
321
+ var STATUS_DEBOUNCE_MS = 300;
322
+ var OUTPUT_BUFFER_MAX_LINES = 20;
323
+ var CLAUDE_APPROVAL_PATTERNS = [
324
+ /do you want to/i,
325
+ /\ballow\b/i,
326
+ /\bdeny\b/i,
327
+ /\bpermission\b/i,
328
+ /proceed\?/i
329
+ ];
330
+ var CLAUDE_INPUT_PATTERNS = [
331
+ /^>\s*$/m,
332
+ /❯/,
333
+ /\$ $/m
334
+ ];
335
+ var CODEX_APPROVAL_PATTERNS = [
336
+ /apply changes/i,
337
+ /\[y\/n\]/i,
338
+ /\bapprove\b/i
339
+ ];
340
+ var CODEX_INPUT_PATTERNS = [
341
+ /what would you like/i,
342
+ /❯/,
343
+ /^>\s*$/m
344
+ ];
345
+ function matchesAny(text, patterns) {
346
+ return patterns.some((pattern) => pattern.test(text));
347
+ }
348
+ var RunWrapper = class {
349
+ runId;
350
+ tool;
351
+ repoPath;
352
+ prompt;
353
+ tmux;
354
+ onEvent;
355
+ onFinish;
356
+ onOutput;
357
+ ptyProcess = null;
358
+ currentStatus = "starting";
359
+ outputBuffer = [];
360
+ debounceTimer = null;
361
+ disposed = false;
362
+ interrupted = false;
363
+ outputSanitizer = new TerminalOutputSanitizer();
364
+ sessionName;
365
+ constructor(options) {
366
+ this.runId = options.runId;
367
+ this.tool = options.tool;
368
+ this.repoPath = options.repoPath;
369
+ this.prompt = options.prompt;
370
+ this.tmux = options.tmux;
371
+ this.onEvent = options.onEvent;
372
+ this.onFinish = options.onFinish;
373
+ this.onOutput = options.onOutput;
374
+ const shortId = this.runId.slice(0, 8);
375
+ this.sessionName = `run-${shortId}`;
376
+ }
377
+ async start() {
378
+ if (this.disposed) {
379
+ return;
380
+ }
381
+ this.emitStatus("starting");
382
+ await this.tmux.createSession(this.sessionName);
383
+ const command = this.buildCommand();
384
+ const ptyProcess = spawn2(
385
+ "tmux",
386
+ ["-L", this.tmux.socketName, "attach-session", "-t", this.sessionName],
387
+ {
388
+ cols: 120,
389
+ rows: 36,
390
+ cwd: this.repoPath,
391
+ env: {
392
+ ...process.env,
393
+ TERM: "xterm-256color"
394
+ },
395
+ name: "xterm-256color"
396
+ }
397
+ );
398
+ this.ptyProcess = ptyProcess;
399
+ ptyProcess.onData((data) => {
400
+ if (this.disposed) {
401
+ return;
402
+ }
403
+ const plainOutput = this.outputSanitizer.push(data);
404
+ if (plainOutput) {
405
+ this.onOutput(plainOutput);
406
+ }
407
+ this.appendToBuffer(data);
408
+ this.scheduleStatusDetection();
409
+ });
410
+ ptyProcess.onExit(({ exitCode }) => {
411
+ if (this.disposed) {
412
+ return;
413
+ }
414
+ if (this.debounceTimer) {
415
+ clearTimeout(this.debounceTimer);
416
+ this.debounceTimer = null;
417
+ }
418
+ const finalStatus = this.interrupted ? "interrupted" : exitCode === 0 ? "success" : "failed";
419
+ if (finalStatus !== this.currentStatus) {
420
+ this.emitStatus(finalStatus);
421
+ }
422
+ const trailingOutput = this.outputSanitizer.flush();
423
+ if (trailingOutput) {
424
+ this.onOutput(trailingOutput);
425
+ }
426
+ this.onFinish(finalStatus);
427
+ this.ptyProcess = null;
428
+ });
429
+ setTimeout(() => {
430
+ if (this.ptyProcess && !this.disposed) {
431
+ this.ptyProcess.write(command + "\n");
432
+ this.emitStatus("running");
433
+ }
434
+ }, 500);
435
+ }
436
+ sendInput(input) {
437
+ if (this.ptyProcess && !this.disposed) {
438
+ this.ptyProcess.write(input);
439
+ }
440
+ }
441
+ interrupt() {
442
+ if (this.ptyProcess && !this.disposed) {
443
+ this.interrupted = true;
444
+ this.ptyProcess.write("");
445
+ this.emitStatus("interrupted");
446
+ }
447
+ }
448
+ approve() {
449
+ if (this.ptyProcess && !this.disposed) {
450
+ this.ptyProcess.write("y\n");
451
+ }
452
+ }
453
+ reject() {
454
+ if (this.ptyProcess && !this.disposed) {
455
+ this.ptyProcess.write("n\n");
456
+ }
457
+ }
458
+ dispose() {
459
+ if (this.disposed) {
460
+ return;
461
+ }
462
+ this.disposed = true;
463
+ if (this.debounceTimer) {
464
+ clearTimeout(this.debounceTimer);
465
+ this.debounceTimer = null;
466
+ }
467
+ if (this.ptyProcess) {
468
+ this.ptyProcess.kill();
469
+ this.ptyProcess = null;
470
+ }
471
+ this.tmux.killSession(this.sessionName).catch(() => {
472
+ });
473
+ }
474
+ buildCommand() {
475
+ const escapedPrompt = this.prompt.replace(/'/g, "'\\''");
476
+ switch (this.tool) {
477
+ case "claude":
478
+ return `cd '${this.repoPath.replace(/'/g, "'\\''")}' && claude '${escapedPrompt}'`;
479
+ case "codex":
480
+ return `cd '${this.repoPath.replace(/'/g, "'\\''")}' && codex '${escapedPrompt}'`;
481
+ }
482
+ }
483
+ appendToBuffer(data) {
484
+ const newLines = data.split("\n");
485
+ this.outputBuffer.push(...newLines);
486
+ if (this.outputBuffer.length > OUTPUT_BUFFER_MAX_LINES) {
487
+ this.outputBuffer = this.outputBuffer.slice(-OUTPUT_BUFFER_MAX_LINES);
488
+ }
489
+ }
490
+ scheduleStatusDetection() {
491
+ if (this.debounceTimer) {
492
+ clearTimeout(this.debounceTimer);
493
+ }
494
+ this.debounceTimer = setTimeout(() => {
495
+ this.debounceTimer = null;
496
+ this.detectStatus();
497
+ }, STATUS_DEBOUNCE_MS);
498
+ }
499
+ detectStatus() {
500
+ if (this.disposed) {
501
+ return;
502
+ }
503
+ if (this.currentStatus === "success" || this.currentStatus === "failed" || this.currentStatus === "interrupted") {
504
+ return;
505
+ }
506
+ const recentText = this.outputBuffer.join("\n");
507
+ const detectedStatus = this.detectStatusFromText(recentText);
508
+ if (detectedStatus && detectedStatus !== this.currentStatus) {
509
+ this.emitStatus(detectedStatus);
510
+ }
511
+ }
512
+ detectStatusFromText(text) {
513
+ const approvalPatterns = this.tool === "claude" ? CLAUDE_APPROVAL_PATTERNS : CODEX_APPROVAL_PATTERNS;
514
+ if (matchesAny(text, approvalPatterns)) {
515
+ return "waiting_approval";
516
+ }
517
+ const inputPatterns = this.tool === "claude" ? CLAUDE_INPUT_PATTERNS : CODEX_INPUT_PATTERNS;
518
+ if (matchesAny(text, inputPatterns)) {
519
+ return "waiting_input";
520
+ }
521
+ if (text.trim().length > 0) {
522
+ return "running";
523
+ }
524
+ return null;
525
+ }
526
+ emitStatus(status, summary, hasDiff) {
527
+ this.currentStatus = status;
528
+ this.onEvent(status, summary, hasDiff);
529
+ }
530
+ };
531
+
532
+ // src/repositories.ts
533
+ import { readdir, stat } from "fs/promises";
534
+ import path from "path";
535
+ async function browseRepositories(options) {
536
+ const rootPath = path.resolve(options.rootPath);
537
+ const currentPath = resolveRequestedPath(rootPath, options.requestedPath);
538
+ const dirents = await readdir(currentPath, { withFileTypes: true });
539
+ const entries = await Promise.all(
540
+ dirents.filter((dirent) => dirent.isDirectory()).map(async (dirent) => classifyEntry(currentPath, dirent.name))
541
+ );
542
+ return {
543
+ currentPath,
544
+ parentPath: currentPath === rootPath ? null : path.dirname(currentPath),
545
+ entries: entries.sort(compareRepositoryEntries)
546
+ };
547
+ }
548
+ async function classifyEntry(basePath, name) {
549
+ const entryPath = path.join(basePath, name);
550
+ const gitPath = path.join(entryPath, ".git");
551
+ try {
552
+ const gitStat = await stat(gitPath);
553
+ if (gitStat.isDirectory() || gitStat.isFile()) {
554
+ return {
555
+ kind: "repository",
556
+ name,
557
+ path: entryPath
558
+ };
559
+ }
560
+ } catch {
561
+ return {
562
+ kind: "directory",
563
+ name,
564
+ path: entryPath
565
+ };
566
+ }
567
+ return {
568
+ kind: "directory",
569
+ name,
570
+ path: entryPath
571
+ };
572
+ }
573
+ function resolveRequestedPath(rootPath, requestedPath) {
574
+ const candidatePath = path.resolve(requestedPath ?? rootPath);
575
+ const relativePath = path.relative(rootPath, candidatePath);
576
+ if (relativePath !== "" && (relativePath.startsWith("..") || path.isAbsolute(relativePath))) {
577
+ throw new Error("Requested path is outside the allowed root");
578
+ }
579
+ return candidatePath;
580
+ }
581
+ function compareRepositoryEntries(left, right) {
582
+ const kindOrder = repositoryKindOrder(left.kind) - repositoryKindOrder(right.kind);
583
+ if (kindOrder !== 0) {
584
+ return kindOrder;
585
+ }
586
+ const hiddenOrder = Number(left.name.startsWith(".")) - Number(right.name.startsWith("."));
587
+ if (hiddenOrder !== 0) {
588
+ return hiddenOrder;
589
+ }
590
+ return left.name.localeCompare(right.name);
591
+ }
592
+ function repositoryKindOrder(kind) {
593
+ return kind === "repository" ? 0 : 1;
594
+ }
595
+
596
+ // src/connection.ts
597
+ var HEARTBEAT_INTERVAL_MS = 3e4;
598
+ var SESSION_SYNC_INTERVAL_MS = 15e3;
599
+ var INITIAL_RECONNECT_DELAY_MS = 1e3;
600
+ var MAX_RECONNECT_DELAY_MS = 3e4;
601
+ var defaultAgentRuntime = {
602
+ version: AGENT_VERSION,
603
+ serviceMode: process.env.WEBMUX_AGENT_SERVICE === "1",
604
+ autoUpgrade: process.env.WEBMUX_AGENT_AUTO_UPGRADE !== "0",
605
+ applyServiceUpgrade: ({ packageName, targetVersion }) => {
606
+ upgradeService({
607
+ agentName: process.env.WEBMUX_AGENT_NAME ?? "webmux-agent",
608
+ packageName,
609
+ version: targetVersion
610
+ });
611
+ },
612
+ exit: (code) => {
613
+ process.exit(code);
614
+ }
615
+ };
616
+ var AgentConnection = class {
617
+ serverUrl;
618
+ agentId;
619
+ agentSecret;
620
+ tmux;
621
+ runtime;
622
+ ws = null;
623
+ heartbeatTimer = null;
624
+ sessionSyncTimer = null;
625
+ reconnectTimer = null;
626
+ reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
627
+ bridges = /* @__PURE__ */ new Map();
628
+ runs = /* @__PURE__ */ new Map();
629
+ stopped = false;
630
+ constructor(serverUrl, agentId, agentSecret, tmux, runtime = defaultAgentRuntime) {
631
+ this.serverUrl = serverUrl;
632
+ this.agentId = agentId;
633
+ this.agentSecret = agentSecret;
634
+ this.tmux = tmux;
635
+ this.runtime = runtime;
636
+ }
637
+ start() {
638
+ this.stopped = false;
639
+ this.connect();
640
+ }
641
+ stop() {
642
+ this.stopped = true;
643
+ if (this.reconnectTimer) {
644
+ clearTimeout(this.reconnectTimer);
645
+ this.reconnectTimer = null;
646
+ }
647
+ this.stopHeartbeat();
648
+ this.stopSessionSync();
649
+ this.disposeAllBridges();
650
+ this.disposeAllRuns();
651
+ if (this.ws) {
652
+ this.ws.close(1e3, "agent shutting down");
653
+ this.ws = null;
654
+ }
655
+ }
656
+ connect() {
657
+ const wsUrl = buildWsUrl(this.serverUrl);
658
+ console.log(`[agent] Connecting to ${wsUrl}`);
659
+ const ws = new WebSocket(wsUrl);
660
+ this.ws = ws;
661
+ ws.on("open", () => {
662
+ console.log("[agent] WebSocket connected, authenticating...");
663
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
664
+ this.sendMessage({
665
+ type: "auth",
666
+ agentId: this.agentId,
667
+ agentSecret: this.agentSecret,
668
+ version: this.runtime.version
669
+ });
670
+ });
671
+ ws.on("message", (raw) => {
672
+ let msg;
673
+ try {
674
+ msg = JSON.parse(raw.toString());
675
+ } catch {
676
+ console.error("[agent] Failed to parse server message:", raw.toString());
677
+ return;
678
+ }
679
+ this.handleMessage(msg);
680
+ });
681
+ ws.on("close", (code, reason) => {
682
+ console.log(`[agent] WebSocket closed: code=${code} reason=${reason.toString()}`);
683
+ this.onDisconnect();
684
+ });
685
+ ws.on("error", (err) => {
686
+ console.error("[agent] WebSocket error:", err.message);
687
+ });
688
+ }
689
+ handleMessage(msg) {
690
+ switch (msg.type) {
691
+ case "auth-ok":
692
+ console.log("[agent] Authenticated successfully");
693
+ if (this.applyRecommendedUpgrade(msg.upgradePolicy)) {
694
+ return;
695
+ }
696
+ this.startHeartbeat();
697
+ this.startSessionSync();
698
+ this.syncSessions();
699
+ break;
700
+ case "auth-fail":
701
+ console.error(`[agent] Authentication failed: ${msg.message}`);
702
+ this.stopped = true;
703
+ if (this.ws) {
704
+ this.ws.close();
705
+ this.ws = null;
706
+ }
707
+ this.runtime.exit(1);
708
+ break;
709
+ case "sessions-list":
710
+ this.syncSessions();
711
+ break;
712
+ case "terminal-attach":
713
+ this.handleTerminalAttach(msg.browserId, msg.sessionName, msg.cols, msg.rows);
714
+ break;
715
+ case "terminal-detach":
716
+ this.handleTerminalDetach(msg.browserId);
717
+ break;
718
+ case "terminal-input":
719
+ this.handleTerminalInput(msg.browserId, msg.data);
720
+ break;
721
+ case "terminal-resize":
722
+ this.handleTerminalResize(msg.browserId, msg.cols, msg.rows);
723
+ break;
724
+ case "session-create":
725
+ this.handleSessionCreate(msg.requestId, msg.name);
726
+ break;
727
+ case "session-kill":
728
+ this.handleSessionKill(msg.requestId, msg.name);
729
+ break;
730
+ case "repository-browse":
731
+ this.handleRepositoryBrowse(msg.requestId, msg.path);
732
+ break;
733
+ case "run-start":
734
+ this.handleRunStart(msg.runId, msg.tool, msg.repoPath, msg.prompt);
735
+ break;
736
+ case "run-input":
737
+ this.handleRunInput(msg.runId, msg.input);
738
+ break;
739
+ case "run-interrupt":
740
+ this.handleRunInterrupt(msg.runId);
741
+ break;
742
+ case "run-kill":
743
+ this.handleRunKill(msg.runId);
744
+ break;
745
+ case "run-approve":
746
+ this.handleRunApprove(msg.runId);
747
+ break;
748
+ case "run-reject":
749
+ this.handleRunReject(msg.runId);
750
+ break;
751
+ default:
752
+ console.warn("[agent] Unknown message type:", msg.type);
753
+ }
754
+ }
755
+ async syncSessions() {
756
+ try {
757
+ const sessions = await this.tmux.listSessions();
758
+ this.sendMessage({ type: "sessions-sync", sessions });
759
+ return sessions;
760
+ } catch (err) {
761
+ console.error("[agent] Failed to list sessions:", err);
762
+ this.sendMessage({ type: "error", message: "Failed to list sessions" });
763
+ return [];
764
+ }
765
+ }
766
+ async handleTerminalAttach(browserId, sessionName, cols, rows) {
767
+ const existing = this.bridges.get(browserId);
768
+ if (existing) {
769
+ existing.dispose();
770
+ this.bridges.delete(browserId);
771
+ }
772
+ try {
773
+ const bridge = await createTerminalBridge({
774
+ tmux: this.tmux,
775
+ sessionName,
776
+ cols,
777
+ rows,
778
+ onData: (data) => {
779
+ this.sendMessage({ type: "terminal-output", browserId, data });
780
+ },
781
+ onExit: (exitCode) => {
782
+ this.bridges.delete(browserId);
783
+ this.sendMessage({ type: "terminal-exit", browserId, exitCode });
784
+ void this.syncSessions();
785
+ }
786
+ });
787
+ this.bridges.set(browserId, bridge);
788
+ this.sendMessage({ type: "terminal-ready", browserId, sessionName });
789
+ await this.syncSessions();
790
+ } catch (err) {
791
+ const message = err instanceof Error ? err.message : String(err);
792
+ console.error(`[agent] Failed to attach terminal for browser ${browserId}:`, message);
793
+ this.sendMessage({ type: "error", browserId, message: `Failed to attach: ${message}` });
794
+ }
795
+ }
796
+ handleTerminalDetach(browserId) {
797
+ const bridge = this.bridges.get(browserId);
798
+ if (bridge) {
799
+ bridge.dispose();
800
+ this.bridges.delete(browserId);
801
+ void this.syncSessions();
802
+ }
803
+ }
804
+ handleTerminalInput(browserId, data) {
805
+ const bridge = this.bridges.get(browserId);
806
+ if (bridge) {
807
+ bridge.write(data);
808
+ }
809
+ }
810
+ handleTerminalResize(browserId, cols, rows) {
811
+ const bridge = this.bridges.get(browserId);
812
+ if (bridge) {
813
+ bridge.resize(cols, rows);
814
+ }
815
+ }
816
+ async handleSessionCreate(requestId, name) {
817
+ try {
818
+ await this.tmux.createSession(name);
819
+ const sessions = await this.syncSessions();
820
+ const session = sessions.find((item) => item.name === name);
821
+ if (!session) {
822
+ throw new Error("Created session was not returned by tmux");
823
+ }
824
+ this.sendMessage({ type: "command-result", requestId, ok: true, session });
825
+ } catch (err) {
826
+ const message = err instanceof Error ? err.message : String(err);
827
+ console.error(`[agent] Failed to create session "${name}":`, message);
828
+ this.sendMessage({ type: "command-result", requestId, ok: false, error: message });
829
+ }
830
+ }
831
+ async handleSessionKill(requestId, name) {
832
+ try {
833
+ await this.tmux.killSession(name);
834
+ await this.syncSessions();
835
+ this.sendMessage({ type: "command-result", requestId, ok: true });
836
+ } catch (err) {
837
+ const message = err instanceof Error ? err.message : String(err);
838
+ console.error(`[agent] Failed to kill session "${name}":`, message);
839
+ this.sendMessage({ type: "command-result", requestId, ok: false, error: message });
840
+ }
841
+ }
842
+ async handleRepositoryBrowse(requestId, requestedPath) {
843
+ try {
844
+ const result = await browseRepositories({
845
+ rootPath: this.tmux.workspaceRoot,
846
+ requestedPath
847
+ });
848
+ this.sendMessage({
849
+ type: "repository-browse-result",
850
+ requestId,
851
+ ok: true,
852
+ currentPath: result.currentPath,
853
+ parentPath: result.parentPath,
854
+ entries: result.entries
855
+ });
856
+ } catch (err) {
857
+ const message = err instanceof Error ? err.message : String(err);
858
+ console.error("[agent] Failed to browse repositories:", message);
859
+ this.sendMessage({ type: "repository-browse-result", requestId, ok: false, error: message });
860
+ }
861
+ }
862
+ sendMessage(msg) {
863
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
864
+ this.ws.send(JSON.stringify(msg));
865
+ }
866
+ }
867
+ applyRecommendedUpgrade(upgradePolicy) {
868
+ const targetVersion = upgradePolicy?.targetVersion;
869
+ if (!targetVersion) {
870
+ return false;
871
+ }
872
+ let comparison;
873
+ try {
874
+ comparison = compareSemanticVersions(this.runtime.version, targetVersion);
875
+ } catch {
876
+ console.warn("[agent] Skipping automatic upgrade because version parsing failed");
877
+ return false;
878
+ }
879
+ if (comparison >= 0) {
880
+ return false;
881
+ }
882
+ console.log(`[agent] Update available: ${this.runtime.version} \u2192 ${targetVersion}`);
883
+ if (!this.runtime.serviceMode || !this.runtime.autoUpgrade) {
884
+ console.log("[agent] Automatic upgrades are only applied for the managed systemd service");
885
+ console.log(`[agent] To upgrade manually, run: pnpm dlx @webmux/agent service upgrade --to ${targetVersion}`);
886
+ return false;
887
+ }
888
+ try {
889
+ this.runtime.applyServiceUpgrade({
890
+ packageName: upgradePolicy.packageName || AGENT_PACKAGE_NAME,
891
+ targetVersion
892
+ });
893
+ console.log(`[agent] Managed service switched to ${targetVersion}. Restarting...`);
894
+ } catch (err) {
895
+ const message = err instanceof Error ? err.message : String(err);
896
+ console.error(`[agent] Failed to apply managed upgrade: ${message}`);
897
+ console.log("[agent] Continuing with current version");
898
+ return false;
899
+ }
900
+ this.stop();
901
+ this.runtime.exit(0);
902
+ return true;
903
+ }
904
+ startHeartbeat() {
905
+ this.stopHeartbeat();
906
+ this.heartbeatTimer = setInterval(() => {
907
+ this.sendMessage({ type: "heartbeat" });
908
+ }, HEARTBEAT_INTERVAL_MS);
909
+ }
910
+ startSessionSync() {
911
+ this.stopSessionSync();
912
+ this.sessionSyncTimer = setInterval(() => {
913
+ void this.syncSessions();
914
+ }, SESSION_SYNC_INTERVAL_MS);
915
+ }
916
+ stopHeartbeat() {
917
+ if (this.heartbeatTimer) {
918
+ clearInterval(this.heartbeatTimer);
919
+ this.heartbeatTimer = null;
920
+ }
921
+ }
922
+ stopSessionSync() {
923
+ if (this.sessionSyncTimer) {
924
+ clearInterval(this.sessionSyncTimer);
925
+ this.sessionSyncTimer = null;
926
+ }
927
+ }
928
+ disposeAllBridges() {
929
+ for (const [browserId, bridge] of this.bridges) {
930
+ bridge.dispose();
931
+ this.bridges.delete(browserId);
932
+ }
933
+ }
934
+ disposeAllRuns() {
935
+ for (const [runId, run] of this.runs) {
936
+ run.dispose();
937
+ this.runs.delete(runId);
938
+ }
939
+ }
940
+ handleRunStart(runId, tool, repoPath, prompt) {
941
+ const existing = this.runs.get(runId);
942
+ if (existing) {
943
+ existing.dispose();
944
+ this.runs.delete(runId);
945
+ }
946
+ const run = new RunWrapper({
947
+ runId,
948
+ tool,
949
+ repoPath,
950
+ prompt,
951
+ tmux: this.tmux,
952
+ onEvent: (status, summary, hasDiff) => {
953
+ this.sendMessage({ type: "run-event", runId, status, summary, hasDiff });
954
+ },
955
+ onFinish: () => {
956
+ this.runs.delete(runId);
957
+ },
958
+ onOutput: (data) => {
959
+ this.sendMessage({ type: "run-output", runId, data });
960
+ }
961
+ });
962
+ this.runs.set(runId, run);
963
+ run.start().catch((err) => {
964
+ const message = err instanceof Error ? err.message : String(err);
965
+ console.error(`[agent] Failed to start run ${runId}:`, message);
966
+ this.sendMessage({
967
+ type: "run-event",
968
+ runId,
969
+ status: "failed",
970
+ summary: `Failed to start: ${message}`
971
+ });
972
+ this.runs.delete(runId);
973
+ });
974
+ }
975
+ handleRunInput(runId, input) {
976
+ const run = this.runs.get(runId);
977
+ if (run) {
978
+ run.sendInput(input);
979
+ } else {
980
+ console.warn(`[agent] run-input: no run found for ${runId}`);
981
+ }
982
+ }
983
+ handleRunInterrupt(runId) {
984
+ const run = this.runs.get(runId);
985
+ if (run) {
986
+ run.interrupt();
987
+ } else {
988
+ console.warn(`[agent] run-interrupt: no run found for ${runId}`);
989
+ }
990
+ }
991
+ handleRunKill(runId) {
992
+ const run = this.runs.get(runId);
993
+ if (run) {
994
+ run.dispose();
995
+ this.runs.delete(runId);
996
+ } else {
997
+ void this.tmux.killSession(getRunSessionName(runId)).catch((err) => {
998
+ const message = err instanceof Error ? err.message : String(err);
999
+ console.warn(`[agent] run-kill: failed to clean session for ${runId}: ${message}`);
1000
+ });
1001
+ }
1002
+ }
1003
+ handleRunApprove(runId) {
1004
+ const run = this.runs.get(runId);
1005
+ if (run) {
1006
+ run.approve();
1007
+ } else {
1008
+ console.warn(`[agent] run-approve: no run found for ${runId}`);
1009
+ }
1010
+ }
1011
+ handleRunReject(runId) {
1012
+ const run = this.runs.get(runId);
1013
+ if (run) {
1014
+ run.reject();
1015
+ } else {
1016
+ console.warn(`[agent] run-reject: no run found for ${runId}`);
1017
+ }
1018
+ }
1019
+ onDisconnect() {
1020
+ this.stopHeartbeat();
1021
+ this.stopSessionSync();
1022
+ this.disposeAllBridges();
1023
+ this.disposeAllRuns();
1024
+ this.ws = null;
1025
+ if (this.stopped) {
1026
+ return;
1027
+ }
1028
+ console.log(`[agent] Reconnecting in ${this.reconnectDelay}ms...`);
1029
+ this.reconnectTimer = setTimeout(() => {
1030
+ this.reconnectTimer = null;
1031
+ this.connect();
1032
+ }, this.reconnectDelay);
1033
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
1034
+ }
1035
+ };
1036
+ function buildWsUrl(serverUrl) {
1037
+ const url = new URL("/ws/agent", serverUrl);
1038
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
1039
+ return url.toString();
1040
+ }
1041
+ function getRunSessionName(runId) {
1042
+ return `run-${runId.slice(0, 8)}`;
1043
+ }
1044
+ export {
1045
+ AgentConnection
1046
+ };