tmux-agent-monitor 0.0.1 → 0.0.3

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/index.js ADDED
@@ -0,0 +1,2599 @@
1
+ #!/usr/bin/env node
2
+ import { a as resolveLogPaths, c as isDangerousCommand, i as decodePaneId, l as allowedKeys, n as configSchema, o as resolveServerKey, r as wsClientMessageSchema, s as compileDangerPatterns, t as claudeHookEventSchema, u as defaultConfig } from "./src-C_y43gN3.mjs";
3
+ import { serve } from "@hono/node-server";
4
+ import { execa } from "execa";
5
+ import qrcode from "qrcode-terminal";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { serveStatic } from "@hono/node-server/serve-static";
10
+ import { createNodeWebSocket } from "@hono/node-ws";
11
+ import { Hono } from "hono";
12
+ import crypto, { randomUUID } from "node:crypto";
13
+ import os, { networkInterfaces } from "node:os";
14
+ import { execFile, execFileSync } from "node:child_process";
15
+ import { promisify } from "node:util";
16
+ import fs$1 from "node:fs/promises";
17
+ import { createServer } from "node:net";
18
+
19
+ //#region packages/tmux/src/adapter.ts
20
+ const buildArgs = (args, options) => {
21
+ const prefix = [];
22
+ if (options.socketName) prefix.push("-L", options.socketName);
23
+ if (options.socketPath) prefix.push("-S", options.socketPath);
24
+ return [...prefix, ...args];
25
+ };
26
+ const createTmuxAdapter = (options = {}) => {
27
+ const run = async (args) => {
28
+ const result = await execa("tmux", buildArgs(args, options), { reject: false });
29
+ return {
30
+ stdout: result.stdout,
31
+ stderr: result.stderr,
32
+ exitCode: result.exitCode ?? 0
33
+ };
34
+ };
35
+ return { run };
36
+ };
37
+
38
+ //#endregion
39
+ //#region packages/tmux/src/inspector.ts
40
+ const format = [
41
+ "#{pane_id}",
42
+ "#{session_name}",
43
+ "#{window_index}",
44
+ "#{pane_index}",
45
+ "#{window_activity}",
46
+ "#{pane_active}",
47
+ "#{pane_current_command}",
48
+ "#{pane_current_path}",
49
+ "#{pane_tty}",
50
+ "#{pane_dead}",
51
+ "#{pane_pipe}",
52
+ "#{alternate_on}",
53
+ "#{pane_pid}",
54
+ "#{pane_title}",
55
+ "#{pane_start_command}",
56
+ "#{@tmux-agent-monitor_pipe}"
57
+ ].join(" ");
58
+ const toNullable = (value) => {
59
+ if (!value) return null;
60
+ const trimmed = value.trim();
61
+ return trimmed.length === 0 ? null : trimmed;
62
+ };
63
+ const toNumber = (value) => {
64
+ if (!value) return null;
65
+ const parsed = Number.parseInt(value, 10);
66
+ return Number.isNaN(parsed) ? null : parsed;
67
+ };
68
+ const toEpochSeconds = (value) => {
69
+ if (!value) return null;
70
+ const parsed = Number.parseInt(value, 10);
71
+ if (Number.isNaN(parsed) || parsed <= 0) return null;
72
+ return parsed;
73
+ };
74
+ const toBool = (value) => {
75
+ return value === "1" || value === "on" || value === "true";
76
+ };
77
+ const parseLine = (line) => {
78
+ if (!line) return null;
79
+ const parts = line.split(" ");
80
+ if (parts.length < 16) return null;
81
+ const [paneIdRaw, sessionNameRaw, windowIndexRaw, paneIndexRaw, windowActivityRaw, paneActiveRaw, currentCommand, currentPath, paneTty, paneDead, panePipe, alternateOn, panePid, paneTitle, paneStartCommand, pipeTagValue] = parts;
82
+ if (!paneIdRaw || !sessionNameRaw) return null;
83
+ const paneId = paneIdRaw;
84
+ const sessionName = sessionNameRaw;
85
+ const windowIndex = windowIndexRaw ?? "0";
86
+ const paneIndex = paneIndexRaw ?? "0";
87
+ return {
88
+ paneId,
89
+ sessionName,
90
+ windowIndex: Number.parseInt(windowIndex, 10),
91
+ paneIndex: Number.parseInt(paneIndex, 10),
92
+ windowActivity: toEpochSeconds(windowActivityRaw),
93
+ paneActive: toBool(paneActiveRaw),
94
+ currentCommand: toNullable(currentCommand),
95
+ currentPath: toNullable(currentPath),
96
+ paneTty: toNullable(paneTty),
97
+ paneDead: toBool(paneDead),
98
+ panePipe: toBool(panePipe),
99
+ alternateOn: toBool(alternateOn),
100
+ panePid: toNumber(panePid),
101
+ paneTitle: toNullable(paneTitle),
102
+ paneStartCommand: toNullable(paneStartCommand),
103
+ pipeTagValue: toNullable(pipeTagValue)
104
+ };
105
+ };
106
+ const createInspector = (adapter) => {
107
+ const listPanes = async () => {
108
+ const result = await adapter.run([
109
+ "list-panes",
110
+ "-a",
111
+ "-F",
112
+ format
113
+ ]);
114
+ if (result.exitCode !== 0) throw new Error(result.stderr || "tmux list-panes failed");
115
+ return result.stdout.split("\n").map((line) => line.replace(/\r$/, "")).filter((line) => line.length > 0).map(parseLine).filter((pane) => pane !== null);
116
+ };
117
+ const readUserOption = async (paneId, key) => {
118
+ const result = await adapter.run([
119
+ "show-options",
120
+ "-t",
121
+ paneId,
122
+ "-v",
123
+ key
124
+ ]);
125
+ if (result.exitCode !== 0) return null;
126
+ return toNullable(result.stdout);
127
+ };
128
+ const writeUserOption = async (paneId, key, value) => {
129
+ if (value === null) {
130
+ await adapter.run([
131
+ "set-option",
132
+ "-t",
133
+ paneId,
134
+ "-u",
135
+ key
136
+ ]);
137
+ return;
138
+ }
139
+ await adapter.run([
140
+ "set-option",
141
+ "-t",
142
+ paneId,
143
+ key,
144
+ value
145
+ ]);
146
+ };
147
+ return {
148
+ listPanes,
149
+ readUserOption,
150
+ writeUserOption
151
+ };
152
+ };
153
+
154
+ //#endregion
155
+ //#region packages/tmux/src/pipe.ts
156
+ const buildPipeCommand = (logPath) => {
157
+ return `cat >> "${logPath.replace(/"/g, "\\\"")}"`;
158
+ };
159
+ const hasConflict = (state) => {
160
+ return state.panePipe && state.pipeTagValue !== "1";
161
+ };
162
+ const createPipeManager = (adapter) => {
163
+ const attachPipe = async (paneId, logPath, state) => {
164
+ if (hasConflict(state)) return {
165
+ attached: false,
166
+ conflict: true
167
+ };
168
+ const command = buildPipeCommand(logPath);
169
+ if ((await adapter.run([
170
+ "pipe-pane",
171
+ "-o",
172
+ "-t",
173
+ paneId,
174
+ command
175
+ ])).exitCode !== 0) return {
176
+ attached: false,
177
+ conflict: false
178
+ };
179
+ await adapter.run([
180
+ "set-option",
181
+ "-t",
182
+ paneId,
183
+ "@tmux-agent-monitor_pipe",
184
+ "1"
185
+ ]);
186
+ return {
187
+ attached: true,
188
+ conflict: false
189
+ };
190
+ };
191
+ return {
192
+ attachPipe,
193
+ hasConflict
194
+ };
195
+ };
196
+
197
+ //#endregion
198
+ //#region packages/tmux/src/screen.ts
199
+ const normalizeScreen = (text, lineLimit) => {
200
+ const lines = text.replace(/\r/g, "").split("\n");
201
+ while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") lines.pop();
202
+ if (lines.length > lineLimit) return lines.slice(-lineLimit).join("\n");
203
+ return lines.join("\n");
204
+ };
205
+ const resolveAltFlag = (altScreen, alternateOn) => {
206
+ if (altScreen === "on") return true;
207
+ if (altScreen === "off") return false;
208
+ return alternateOn;
209
+ };
210
+ const getPaneSize = async (adapter, paneId) => {
211
+ const result = await adapter.run([
212
+ "display-message",
213
+ "-p",
214
+ "-t",
215
+ paneId,
216
+ "#{history_size} #{pane_height}"
217
+ ]);
218
+ if (result.exitCode !== 0) return null;
219
+ const [historySize, paneHeight] = result.stdout.trim().split(" ");
220
+ const history = Number.parseInt(historySize ?? "", 10);
221
+ const height = Number.parseInt(paneHeight ?? "", 10);
222
+ if (Number.isNaN(history) || Number.isNaN(height)) return null;
223
+ return {
224
+ historySize: history,
225
+ paneHeight: height
226
+ };
227
+ };
228
+ const createScreenCapture = (adapter) => {
229
+ const captureText = async (options) => {
230
+ const args = [
231
+ "capture-pane",
232
+ "-p",
233
+ "-t",
234
+ options.paneId
235
+ ];
236
+ if (options.joinLines) args.push("-J");
237
+ if (options.includeAnsi) args.push("-e");
238
+ if (resolveAltFlag(options.altScreen, options.alternateOn)) args.push("-a");
239
+ args.push("-S", `-${options.lines}`, "-E", "-");
240
+ const result = await adapter.run(args);
241
+ if (result.exitCode !== 0) throw new Error(result.stderr || "capture-pane failed");
242
+ const size = await getPaneSize(adapter, options.paneId);
243
+ const truncated = size === null ? null : size.historySize + size.paneHeight > options.lines;
244
+ return {
245
+ screen: normalizeScreen(result.stdout, options.lines),
246
+ truncated,
247
+ alternateOn: options.alternateOn
248
+ };
249
+ };
250
+ return { captureText };
251
+ };
252
+
253
+ //#endregion
254
+ //#region apps/server/src/config.ts
255
+ const configDirName = ".tmux-agent-monitor";
256
+ const getConfigDir = () => {
257
+ return path.join(os.homedir(), configDirName);
258
+ };
259
+ const getConfigPath = () => {
260
+ return path.join(getConfigDir(), "config.json");
261
+ };
262
+ const ensureDir$1 = (dir) => {
263
+ fs.mkdirSync(dir, {
264
+ recursive: true,
265
+ mode: 448
266
+ });
267
+ };
268
+ const writeFileSafe = (filePath, data) => {
269
+ fs.writeFileSync(filePath, data, {
270
+ encoding: "utf8",
271
+ mode: 384
272
+ });
273
+ try {
274
+ fs.chmodSync(filePath, 384);
275
+ } catch {}
276
+ };
277
+ const generateToken = () => {
278
+ return crypto.randomBytes(32).toString("hex");
279
+ };
280
+ const loadConfig = () => {
281
+ const configPath = getConfigPath();
282
+ try {
283
+ const raw = fs.readFileSync(configPath, "utf8");
284
+ const parsed = configSchema.safeParse(JSON.parse(raw));
285
+ if (!parsed.success) return null;
286
+ return parsed.data;
287
+ } catch {
288
+ return null;
289
+ }
290
+ };
291
+ const saveConfig = (config) => {
292
+ ensureDir$1(getConfigDir());
293
+ writeFileSafe(getConfigPath(), `${JSON.stringify(config, null, 2)}\n`);
294
+ };
295
+ const ensureConfig = (overrides) => {
296
+ const existing = loadConfig();
297
+ if (existing) {
298
+ let next = existing;
299
+ let migrated = false;
300
+ if (existing.port === 10080 && defaultConfig.port === 11080) {
301
+ next = {
302
+ ...existing,
303
+ port: defaultConfig.port
304
+ };
305
+ migrated = true;
306
+ }
307
+ if (existing.screen?.image?.enabled === false && defaultConfig.screen.image.enabled === true) {
308
+ next = {
309
+ ...next,
310
+ screen: {
311
+ ...next.screen,
312
+ image: {
313
+ ...next.screen.image,
314
+ enabled: true
315
+ }
316
+ }
317
+ };
318
+ migrated = true;
319
+ }
320
+ if (migrated) saveConfig(next);
321
+ return {
322
+ ...next,
323
+ ...overrides
324
+ };
325
+ }
326
+ const token = generateToken();
327
+ const config = {
328
+ ...defaultConfig,
329
+ ...overrides,
330
+ token
331
+ };
332
+ saveConfig(config);
333
+ return config;
334
+ };
335
+ const rotateToken = () => {
336
+ const config = ensureConfig();
337
+ const token = generateToken();
338
+ const next = {
339
+ ...config,
340
+ token
341
+ };
342
+ saveConfig(next);
343
+ return next;
344
+ };
345
+
346
+ //#endregion
347
+ //#region apps/server/src/git-commits.ts
348
+ const execFileAsync$3 = promisify(execFile);
349
+ const LOG_TTL_MS = 3e3;
350
+ const DETAIL_TTL_MS = 3e3;
351
+ const FILE_TTL_MS$1 = 3e3;
352
+ const MAX_PATCH_BYTES$1 = 2e6;
353
+ const MAX_OUTPUT_BUFFER$1 = 2e7;
354
+ const RECORD_SEPARATOR = "";
355
+ const FIELD_SEPARATOR = "";
356
+ const nowIso$1 = () => (/* @__PURE__ */ new Date()).toISOString();
357
+ const logCache = /* @__PURE__ */ new Map();
358
+ const detailCache = /* @__PURE__ */ new Map();
359
+ const fileCache$1 = /* @__PURE__ */ new Map();
360
+ const runGit$1 = async (cwd, args) => {
361
+ try {
362
+ return (await execFileAsync$3("git", [
363
+ "-C",
364
+ cwd,
365
+ ...args
366
+ ], {
367
+ encoding: "utf8",
368
+ timeout: 5e3,
369
+ maxBuffer: MAX_OUTPUT_BUFFER$1
370
+ })).stdout ?? "";
371
+ } catch (err) {
372
+ if (err && typeof err === "object" && "stdout" in err) return err.stdout ?? "";
373
+ throw err;
374
+ }
375
+ };
376
+ const resolveRepoRoot$1 = async (cwd) => {
377
+ try {
378
+ const trimmed = (await runGit$1(cwd, ["rev-parse", "--show-toplevel"])).trim();
379
+ return trimmed.length > 0 ? trimmed : null;
380
+ } catch {
381
+ return null;
382
+ }
383
+ };
384
+ const resolveHead = async (repoRoot) => {
385
+ try {
386
+ const trimmed = (await runGit$1(repoRoot, ["rev-parse", "HEAD"])).trim();
387
+ return trimmed.length > 0 ? trimmed : null;
388
+ } catch {
389
+ return null;
390
+ }
391
+ };
392
+ const pickStatus$1 = (value) => {
393
+ const allowed = [
394
+ "A",
395
+ "M",
396
+ "D",
397
+ "R",
398
+ "C",
399
+ "U",
400
+ "?"
401
+ ];
402
+ const status = value.toUpperCase().slice(0, 1);
403
+ return allowed.includes(status) ? status : "?";
404
+ };
405
+ const isBinaryPatch$1 = (patch) => patch.includes("Binary files ") || patch.includes("GIT binary patch") || patch.includes("literal ");
406
+ const parseCommitLogOutput = (output) => {
407
+ if (!output) return [];
408
+ const records = output.split(RECORD_SEPARATOR).filter((record) => record.trim().length > 0);
409
+ const commits = [];
410
+ for (const record of records) {
411
+ const [hash = "", shortHash = "", authorName = "", authorEmailRaw = "", authoredAt = "", subject = "", bodyRaw = ""] = record.split(FIELD_SEPARATOR);
412
+ if (!hash) continue;
413
+ const body = bodyRaw.trim().length > 0 ? bodyRaw : null;
414
+ const authorEmail = authorEmailRaw.trim().length > 0 ? authorEmailRaw : null;
415
+ commits.push({
416
+ hash,
417
+ shortHash,
418
+ subject,
419
+ body,
420
+ authorName,
421
+ authorEmail,
422
+ authoredAt
423
+ });
424
+ }
425
+ return commits;
426
+ };
427
+ const parseNumstat$1 = (output) => {
428
+ const stats = /* @__PURE__ */ new Map();
429
+ const lines = output.split("\n").filter((line) => line.trim().length > 0);
430
+ for (const line of lines) {
431
+ const parts = line.split(" ");
432
+ if (parts.length < 3) continue;
433
+ const addRaw = parts[0] ?? "";
434
+ const delRaw = parts[1] ?? "";
435
+ const pathValue = parts[parts.length - 1] ?? "";
436
+ const additions = addRaw === "-" ? null : Number.parseInt(addRaw, 10);
437
+ const deletions = delRaw === "-" ? null : Number.parseInt(delRaw, 10);
438
+ stats.set(pathValue, {
439
+ additions: Number.isFinite(additions) ? additions : null,
440
+ deletions: Number.isFinite(deletions) ? deletions : null
441
+ });
442
+ }
443
+ return stats;
444
+ };
445
+ const parseNameStatusOutput = (output) => {
446
+ const files = [];
447
+ const lines = output.split("\n").filter((line) => line.trim().length > 0);
448
+ for (const line of lines) {
449
+ const parts = line.split(" ");
450
+ if (parts.length < 2) continue;
451
+ const status = pickStatus$1(parts[0] ?? "");
452
+ if (status === "R" || status === "C") {
453
+ if (parts.length >= 3) files.push({
454
+ status,
455
+ renamedFrom: parts[1] ?? void 0,
456
+ path: parts[2] ?? parts[1] ?? "",
457
+ additions: null,
458
+ deletions: null
459
+ });
460
+ continue;
461
+ }
462
+ files.push({
463
+ status,
464
+ path: parts[1] ?? "",
465
+ additions: null,
466
+ deletions: null
467
+ });
468
+ }
469
+ return files.filter((file) => file.path.length > 0);
470
+ };
471
+ const findStatForFile = (stats, file) => {
472
+ const direct = stats.get(file.path);
473
+ if (direct) return direct;
474
+ if (file.renamedFrom) {
475
+ const renameDirect = stats.get(file.renamedFrom);
476
+ if (renameDirect) return renameDirect;
477
+ }
478
+ for (const [key, value] of stats.entries()) {
479
+ if (file.renamedFrom && key.includes(file.renamedFrom) && key.includes(file.path)) return value;
480
+ if (key.includes(file.path)) return value;
481
+ }
482
+ return null;
483
+ };
484
+ const buildCommitLogSignature = (log) => {
485
+ return JSON.stringify({
486
+ repoRoot: log.repoRoot ?? null,
487
+ rev: log.rev ?? null,
488
+ reason: log.reason ?? null,
489
+ commits: log.commits.map((commit) => commit.hash)
490
+ });
491
+ };
492
+ const fetchCommitLog = async (cwd, options) => {
493
+ if (!cwd) return {
494
+ repoRoot: null,
495
+ rev: null,
496
+ generatedAt: nowIso$1(),
497
+ commits: [],
498
+ reason: "cwd_unknown"
499
+ };
500
+ const repoRoot = await resolveRepoRoot$1(cwd);
501
+ if (!repoRoot) return {
502
+ repoRoot: null,
503
+ rev: null,
504
+ generatedAt: nowIso$1(),
505
+ commits: [],
506
+ reason: "not_git"
507
+ };
508
+ const limit = Math.max(1, Math.min(options?.limit ?? 10, 50));
509
+ const skip = Math.max(0, options?.skip ?? 0);
510
+ const head = await resolveHead(repoRoot);
511
+ const cacheKey = `${repoRoot}:${limit}:${skip}`;
512
+ const cached = logCache.get(cacheKey);
513
+ const nowMs = Date.now();
514
+ if (!options?.force && cached && nowMs - cached.at < LOG_TTL_MS && cached.rev === head) return cached.log;
515
+ try {
516
+ const format = [
517
+ RECORD_SEPARATOR,
518
+ "%H",
519
+ FIELD_SEPARATOR,
520
+ "%h",
521
+ FIELD_SEPARATOR,
522
+ "%an",
523
+ FIELD_SEPARATOR,
524
+ "%ae",
525
+ FIELD_SEPARATOR,
526
+ "%ad",
527
+ FIELD_SEPARATOR,
528
+ "%s",
529
+ FIELD_SEPARATOR,
530
+ "%b"
531
+ ].join("");
532
+ const commits = parseCommitLogOutput(await runGit$1(repoRoot, [
533
+ "log",
534
+ "-n",
535
+ String(limit),
536
+ "--skip",
537
+ String(skip),
538
+ "--date=iso-strict",
539
+ `--format=${format}`
540
+ ]));
541
+ const log = {
542
+ repoRoot,
543
+ rev: head,
544
+ generatedAt: nowIso$1(),
545
+ commits
546
+ };
547
+ logCache.set(cacheKey, {
548
+ at: nowMs,
549
+ rev: head,
550
+ log,
551
+ signature: buildCommitLogSignature(log)
552
+ });
553
+ return log;
554
+ } catch {
555
+ return {
556
+ repoRoot,
557
+ rev: head,
558
+ generatedAt: nowIso$1(),
559
+ commits: [],
560
+ reason: "error"
561
+ };
562
+ }
563
+ };
564
+ const fetchCommitDetail = async (repoRoot, hash, options) => {
565
+ const cacheKey = `${repoRoot}:${hash}`;
566
+ const cached = detailCache.get(cacheKey);
567
+ const nowMs = Date.now();
568
+ if (!options?.force && cached && nowMs - cached.at < DETAIL_TTL_MS) return cached.detail;
569
+ try {
570
+ const meta = parseCommitLogOutput(await runGit$1(repoRoot, [
571
+ "show",
572
+ "-s",
573
+ "--date=iso-strict",
574
+ `--format=${[
575
+ RECORD_SEPARATOR,
576
+ "%H",
577
+ FIELD_SEPARATOR,
578
+ "%h",
579
+ FIELD_SEPARATOR,
580
+ "%an",
581
+ FIELD_SEPARATOR,
582
+ "%ae",
583
+ FIELD_SEPARATOR,
584
+ "%ad",
585
+ FIELD_SEPARATOR,
586
+ "%s",
587
+ FIELD_SEPARATOR,
588
+ "%b"
589
+ ].join("")}`,
590
+ hash
591
+ ]))[0];
592
+ if (!meta) return null;
593
+ const nameStatusOutput = await runGit$1(repoRoot, [
594
+ "show",
595
+ "--name-status",
596
+ "--format=",
597
+ hash
598
+ ]);
599
+ const numstatOutput = await runGit$1(repoRoot, [
600
+ "show",
601
+ "--numstat",
602
+ "--format=",
603
+ hash
604
+ ]);
605
+ const files = parseNameStatusOutput(nameStatusOutput);
606
+ const stats = parseNumstat$1(numstatOutput);
607
+ const withStats = files.map((file) => {
608
+ const stat = findStatForFile(stats, file);
609
+ return {
610
+ ...file,
611
+ additions: stat?.additions ?? null,
612
+ deletions: stat?.deletions ?? null
613
+ };
614
+ });
615
+ const detail = {
616
+ ...meta,
617
+ files: withStats
618
+ };
619
+ detailCache.set(cacheKey, {
620
+ at: nowMs,
621
+ detail
622
+ });
623
+ return detail;
624
+ } catch {
625
+ return null;
626
+ }
627
+ };
628
+ const fetchCommitFile = async (repoRoot, hash, file, options) => {
629
+ const cacheKey = `${repoRoot}:${hash}:${file.path}`;
630
+ const cached = fileCache$1.get(cacheKey);
631
+ const nowMs = Date.now();
632
+ if (!options?.force && cached && nowMs - cached.at < FILE_TTL_MS$1) return cached.file;
633
+ let patch = "";
634
+ try {
635
+ patch = await runGit$1(repoRoot, [
636
+ "show",
637
+ "--find-renames",
638
+ hash,
639
+ "--",
640
+ file.path
641
+ ]);
642
+ if (!patch && file.renamedFrom) patch = await runGit$1(repoRoot, [
643
+ "show",
644
+ "--find-renames",
645
+ hash,
646
+ "--",
647
+ file.renamedFrom
648
+ ]);
649
+ } catch {
650
+ patch = "";
651
+ }
652
+ const binary = isBinaryPatch$1(patch) || file.additions === null || file.deletions === null;
653
+ let truncated = false;
654
+ if (patch.length > MAX_PATCH_BYTES$1) {
655
+ truncated = true;
656
+ patch = patch.slice(0, MAX_PATCH_BYTES$1);
657
+ }
658
+ const diff = {
659
+ path: file.path,
660
+ status: file.status,
661
+ patch: patch.length > 0 ? patch : null,
662
+ binary,
663
+ truncated
664
+ };
665
+ fileCache$1.set(cacheKey, {
666
+ at: nowMs,
667
+ file: diff
668
+ });
669
+ return diff;
670
+ };
671
+
672
+ //#endregion
673
+ //#region apps/server/src/git-diff.ts
674
+ const execFileAsync$2 = promisify(execFile);
675
+ const SUMMARY_TTL_MS = 3e3;
676
+ const FILE_TTL_MS = 3e3;
677
+ const MAX_PATCH_BYTES = 2e6;
678
+ const MAX_OUTPUT_BUFFER = 2e7;
679
+ const nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
680
+ const summaryCache = /* @__PURE__ */ new Map();
681
+ const fileCache = /* @__PURE__ */ new Map();
682
+ const createRevision = (statusOutput) => crypto.createHash("sha1").update(statusOutput).digest("hex");
683
+ const runGit = async (cwd, args) => {
684
+ try {
685
+ return (await execFileAsync$2("git", [
686
+ "-C",
687
+ cwd,
688
+ ...args
689
+ ], {
690
+ encoding: "utf8",
691
+ timeout: 5e3,
692
+ maxBuffer: MAX_OUTPUT_BUFFER
693
+ })).stdout ?? "";
694
+ } catch (err) {
695
+ if (err && typeof err === "object" && "stdout" in err) return err.stdout ?? "";
696
+ throw err;
697
+ }
698
+ };
699
+ const resolveRepoRoot = async (cwd) => {
700
+ try {
701
+ const trimmed = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).trim();
702
+ return trimmed.length > 0 ? trimmed : null;
703
+ } catch {
704
+ return null;
705
+ }
706
+ };
707
+ const pickStatus = (value) => {
708
+ return [
709
+ "A",
710
+ "M",
711
+ "D",
712
+ "R",
713
+ "C",
714
+ "U",
715
+ "?"
716
+ ].includes(value) ? value : "?";
717
+ };
718
+ const parseNumstat = (output) => {
719
+ const stats = /* @__PURE__ */ new Map();
720
+ const lines = output.split("\n").filter((line) => line.trim().length > 0);
721
+ for (const line of lines) {
722
+ const parts = line.split(" ");
723
+ if (parts.length < 3) continue;
724
+ const addRaw = parts[0] ?? "";
725
+ const delRaw = parts[1] ?? "";
726
+ const pathValue = parts[parts.length - 1] ?? "";
727
+ const additions = addRaw === "-" ? null : Number.parseInt(addRaw, 10);
728
+ const deletions = delRaw === "-" ? null : Number.parseInt(delRaw, 10);
729
+ stats.set(pathValue, {
730
+ additions: Number.isFinite(additions) ? additions : null,
731
+ deletions: Number.isFinite(deletions) ? deletions : null
732
+ });
733
+ }
734
+ return stats;
735
+ };
736
+ const parseNumstatLine = (output) => {
737
+ const line = output.split("\n").map((value) => value.trim()).find((value) => value.length > 0);
738
+ if (!line) return null;
739
+ const parts = line.split(" ");
740
+ if (parts.length < 2) return null;
741
+ const addRaw = parts[0] ?? "";
742
+ const delRaw = parts[1] ?? "";
743
+ const additions = addRaw === "-" ? null : Number.parseInt(addRaw, 10);
744
+ const deletions = delRaw === "-" ? null : Number.parseInt(delRaw, 10);
745
+ return {
746
+ additions: Number.isFinite(additions) ? additions : null,
747
+ deletions: Number.isFinite(deletions) ? deletions : null
748
+ };
749
+ };
750
+ const parseGitStatus = (statusOutput) => {
751
+ if (!statusOutput) return [];
752
+ const tokens = statusOutput.split("\0").filter((token) => token.length > 0);
753
+ const files = [];
754
+ for (let i = 0; i < tokens.length; i += 1) {
755
+ const token = tokens[i] ?? "";
756
+ if (token.length < 3) continue;
757
+ const statusCode = token.slice(0, 2);
758
+ if (statusCode === "!!") continue;
759
+ const rawPath = token.length > 3 ? token.slice(3) : "";
760
+ if (!rawPath) continue;
761
+ let pathValue = rawPath;
762
+ let renamedFrom;
763
+ const xStatus = statusCode[0] ?? " ";
764
+ const yStatus = statusCode[1] ?? " ";
765
+ if ((xStatus === "R" || xStatus === "C" || yStatus === "R" || yStatus === "C") && tokens[i + 1]) {
766
+ renamedFrom = rawPath;
767
+ pathValue = tokens[i + 1] ?? rawPath;
768
+ i += 1;
769
+ }
770
+ const staged = xStatus !== " " && xStatus !== "?";
771
+ let status;
772
+ if (statusCode === "??") status = "?";
773
+ else if (xStatus !== " ") status = pickStatus(xStatus);
774
+ else status = pickStatus(yStatus);
775
+ files.push({
776
+ path: pathValue,
777
+ status,
778
+ staged,
779
+ renamedFrom
780
+ });
781
+ }
782
+ return files;
783
+ };
784
+ const resolveSafePath = (repoRoot, filePath) => {
785
+ const resolved = path.resolve(repoRoot, filePath);
786
+ const normalizedRoot = repoRoot.endsWith(path.sep) ? repoRoot : `${repoRoot}${path.sep}`;
787
+ if (!resolved.startsWith(normalizedRoot)) return null;
788
+ return resolved;
789
+ };
790
+ const fetchDiffSummary = async (cwd, options) => {
791
+ if (!cwd) return {
792
+ repoRoot: null,
793
+ rev: null,
794
+ generatedAt: nowIso(),
795
+ files: [],
796
+ reason: "cwd_unknown"
797
+ };
798
+ const repoRoot = await resolveRepoRoot(cwd);
799
+ if (!repoRoot) return {
800
+ repoRoot: null,
801
+ rev: null,
802
+ generatedAt: nowIso(),
803
+ files: [],
804
+ reason: "not_git"
805
+ };
806
+ const cached = summaryCache.get(repoRoot);
807
+ const nowMs = Date.now();
808
+ if (!options?.force && cached && nowMs - cached.at < SUMMARY_TTL_MS) return cached.summary;
809
+ try {
810
+ const statusOutput = await runGit(repoRoot, [
811
+ "status",
812
+ "--porcelain",
813
+ "-z"
814
+ ]);
815
+ const files = parseGitStatus(statusOutput);
816
+ const stats = parseNumstat(await runGit(repoRoot, [
817
+ "diff",
818
+ "HEAD",
819
+ "--numstat",
820
+ "--"
821
+ ]));
822
+ const untrackedStats = /* @__PURE__ */ new Map();
823
+ for (const file of files) {
824
+ if (file.status !== "?") continue;
825
+ const safePath = resolveSafePath(repoRoot, file.path);
826
+ if (!safePath) continue;
827
+ const parsed = parseNumstatLine(await runGit(repoRoot, [
828
+ "diff",
829
+ "--no-index",
830
+ "--numstat",
831
+ "--",
832
+ "/dev/null",
833
+ safePath
834
+ ]));
835
+ if (parsed) untrackedStats.set(file.path, parsed);
836
+ }
837
+ const withStats = files.map((file) => {
838
+ const stat = file.status === "?" ? untrackedStats.get(file.path) : stats.get(file.path);
839
+ return {
840
+ ...file,
841
+ additions: stat?.additions ?? null,
842
+ deletions: stat?.deletions ?? null
843
+ };
844
+ });
845
+ const summary = {
846
+ repoRoot,
847
+ rev: createRevision(statusOutput),
848
+ generatedAt: nowIso(),
849
+ files: withStats
850
+ };
851
+ summaryCache.set(repoRoot, {
852
+ at: nowMs,
853
+ summary,
854
+ statusOutput
855
+ });
856
+ return summary;
857
+ } catch {
858
+ return {
859
+ repoRoot,
860
+ rev: null,
861
+ generatedAt: nowIso(),
862
+ files: [],
863
+ reason: "error"
864
+ };
865
+ }
866
+ };
867
+ const isBinaryPatch = (patch) => patch.includes("Binary files ") || patch.includes("GIT binary patch") || patch.includes("literal ");
868
+ const fetchDiffFile = async (repoRoot, file, rev, options) => {
869
+ const cacheKey = `${repoRoot}:${file.path}:${rev}`;
870
+ const cached = fileCache.get(cacheKey);
871
+ const nowMs = Date.now();
872
+ if (!options?.force && cached && nowMs - cached.at < FILE_TTL_MS) return cached.file;
873
+ const safePath = resolveSafePath(repoRoot, file.path);
874
+ if (!safePath) return {
875
+ path: file.path,
876
+ status: file.status,
877
+ patch: null,
878
+ binary: false,
879
+ truncated: false,
880
+ rev
881
+ };
882
+ let patch = "";
883
+ let numstat = null;
884
+ try {
885
+ if (file.status === "?") {
886
+ patch = await runGit(repoRoot, [
887
+ "diff",
888
+ "--no-index",
889
+ "--",
890
+ "/dev/null",
891
+ safePath
892
+ ]);
893
+ numstat = parseNumstatLine(await runGit(repoRoot, [
894
+ "diff",
895
+ "--no-index",
896
+ "--numstat",
897
+ "--",
898
+ "/dev/null",
899
+ safePath
900
+ ]));
901
+ } else {
902
+ patch = await runGit(repoRoot, [
903
+ "diff",
904
+ "HEAD",
905
+ "--",
906
+ file.path
907
+ ]);
908
+ numstat = parseNumstatLine(await runGit(repoRoot, [
909
+ "diff",
910
+ "HEAD",
911
+ "--numstat",
912
+ "--",
913
+ file.path
914
+ ]));
915
+ }
916
+ } catch {
917
+ patch = "";
918
+ }
919
+ const binary = isBinaryPatch(patch) || numstat?.additions === null || numstat?.deletions === null;
920
+ let truncated = false;
921
+ if (patch.length > MAX_PATCH_BYTES) {
922
+ truncated = true;
923
+ patch = patch.slice(0, MAX_PATCH_BYTES);
924
+ }
925
+ const diffFile = {
926
+ path: file.path,
927
+ status: file.status,
928
+ patch: patch.length > 0 ? patch : null,
929
+ binary,
930
+ truncated,
931
+ rev
932
+ };
933
+ fileCache.set(cacheKey, {
934
+ at: nowMs,
935
+ rev,
936
+ file: diffFile
937
+ });
938
+ return diffFile;
939
+ };
940
+
941
+ //#endregion
942
+ //#region apps/server/src/activity-suppressor.ts
943
+ const lastPaneFocusAt = /* @__PURE__ */ new Map();
944
+ const SUPPRESS_WINDOW_MS = 2e3;
945
+ const STALE_WINDOW_MS = 15e3;
946
+ const markPaneFocus = (paneId) => {
947
+ if (!paneId) return;
948
+ lastPaneFocusAt.set(paneId, Date.now());
949
+ };
950
+ const shouldSuppressActivity = (paneId, activityIso) => {
951
+ if (!paneId || !activityIso) return false;
952
+ const lastFocus = lastPaneFocusAt.get(paneId);
953
+ if (!lastFocus) return false;
954
+ const activityTs = Date.parse(activityIso);
955
+ if (Number.isNaN(activityTs)) return false;
956
+ if (Date.now() - lastFocus > STALE_WINDOW_MS) {
957
+ lastPaneFocusAt.delete(paneId);
958
+ return false;
959
+ }
960
+ return activityTs >= lastFocus && activityTs - lastFocus <= SUPPRESS_WINDOW_MS;
961
+ };
962
+
963
+ //#endregion
964
+ //#region apps/server/src/screen-service.ts
965
+ const execFileAsync$1 = promisify(execFile);
966
+ const isMacOS = () => process.platform === "darwin";
967
+ const TTY_PATH_PATTERN = /^\/dev\/(ttys?\d+|pts\/\d+)$/;
968
+ const normalizeTty$1 = (tty) => tty.startsWith("/dev/") ? tty : `/dev/${tty}`;
969
+ const isValidTty = (tty) => TTY_PATH_PATTERN.test(normalizeTty$1(tty));
970
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
971
+ const parseBounds = (input) => {
972
+ const parts = input.split(",").map((value) => Number.parseInt(value.trim(), 10)).filter((value) => !Number.isNaN(value));
973
+ if (parts.length !== 4) return null;
974
+ const [x, y, width, height] = parts;
975
+ if (x === void 0 || y === void 0 || width === void 0 || height === void 0) return null;
976
+ return {
977
+ x,
978
+ y,
979
+ width,
980
+ height
981
+ };
982
+ };
983
+ const runAppleScript = async (script) => {
984
+ try {
985
+ return ((await execFileAsync$1("osascript", ["-e", script], { encoding: "utf8" })).stdout ?? "").trim();
986
+ } catch {
987
+ return "";
988
+ }
989
+ };
990
+ const buildTerminalBoundsScript = (appName) => `
991
+ tell application "System Events"
992
+ if not (exists process "${appName}") then return ""
993
+ tell process "${appName}"
994
+ try
995
+ set windowFrame to value of attribute "AXFrame" of front window
996
+ set pos to {item 1 of windowFrame, item 2 of windowFrame}
997
+ set sz to {item 3 of windowFrame, item 4 of windowFrame}
998
+ set contentPos to pos
999
+ set contentSize to sz
1000
+ try
1001
+ set scrollArea to first UI element of front window whose role is "AXScrollArea"
1002
+ set contentFrame to value of attribute "AXFrame" of scrollArea
1003
+ set contentPos to {item 1 of contentFrame, item 2 of contentFrame}
1004
+ set contentSize to {item 3 of contentFrame, item 4 of contentFrame}
1005
+ end try
1006
+ return (item 1 of contentPos as text) & ", " & (item 2 of contentPos as text) & ", " & (item 1 of contentSize as text) & ", " & (item 2 of contentSize as text) & "|" & (item 1 of pos as text) & ", " & (item 2 of pos as text) & ", " & (item 1 of sz as text) & ", " & (item 2 of sz as text)
1007
+ end try
1008
+ end tell
1009
+ end tell
1010
+ return ""
1011
+ `;
1012
+ const focusTerminalApp = async (appName) => {
1013
+ await runAppleScript(`tell application "${appName}" to activate`);
1014
+ };
1015
+ const captureRegion = async (bounds) => {
1016
+ const tempPath = `/tmp/tmux-agent-monitor-${randomUUID()}.png`;
1017
+ const region = `${bounds.x},${bounds.y},${bounds.width},${bounds.height}`;
1018
+ try {
1019
+ await execFileAsync$1("screencapture", [
1020
+ "-R",
1021
+ region,
1022
+ "-x",
1023
+ tempPath
1024
+ ], { timeout: 1e4 });
1025
+ const data = await fs$1.readFile(tempPath);
1026
+ await fs$1.unlink(tempPath).catch(() => null);
1027
+ return data.toString("base64");
1028
+ } catch {
1029
+ await fs$1.unlink(tempPath).catch(() => null);
1030
+ return null;
1031
+ }
1032
+ };
1033
+ const parsePaneGeometry = (input) => {
1034
+ const parts = input.trim().split(" ").map((value) => Number.parseInt(value.trim(), 10));
1035
+ if (parts.length !== 6 || parts.some((value) => Number.isNaN(value))) return null;
1036
+ const [left, top, width, height, windowWidth, windowHeight] = parts;
1037
+ if (left === void 0 || top === void 0 || width === void 0 || height === void 0 || windowWidth === void 0 || windowHeight === void 0) return null;
1038
+ return {
1039
+ left,
1040
+ top,
1041
+ width,
1042
+ height,
1043
+ windowWidth,
1044
+ windowHeight
1045
+ };
1046
+ };
1047
+ const buildTmuxArgs = (args, options) => {
1048
+ const prefix = [];
1049
+ if (options?.socketName) prefix.push("-L", options.socketName);
1050
+ if (options?.socketPath) prefix.push("-S", options.socketPath);
1051
+ return [...prefix, ...args];
1052
+ };
1053
+ const getPaneSession = async (paneId, options) => {
1054
+ try {
1055
+ const name = ((await execFileAsync$1("tmux", buildTmuxArgs([
1056
+ "display-message",
1057
+ "-p",
1058
+ "-t",
1059
+ paneId,
1060
+ "-F",
1061
+ "#{session_name}"
1062
+ ], options), {
1063
+ encoding: "utf8",
1064
+ timeout: 2e3
1065
+ })).stdout ?? "").trim();
1066
+ return name.length > 0 ? name : null;
1067
+ } catch {
1068
+ return null;
1069
+ }
1070
+ };
1071
+ const focusTmuxPane = async (paneId, options) => {
1072
+ if (!paneId) return;
1073
+ if (options?.primaryClient) await execFileAsync$1("tmux", buildTmuxArgs([
1074
+ "switch-client",
1075
+ "-t",
1076
+ options.primaryClient
1077
+ ], options), {
1078
+ encoding: "utf8",
1079
+ timeout: 2e3
1080
+ }).catch(() => null);
1081
+ const sessionName = await getPaneSession(paneId, options);
1082
+ if (sessionName) await execFileAsync$1("tmux", buildTmuxArgs([
1083
+ "switch-client",
1084
+ "-t",
1085
+ sessionName
1086
+ ], options), {
1087
+ encoding: "utf8",
1088
+ timeout: 2e3
1089
+ }).catch(() => null);
1090
+ await execFileAsync$1("tmux", buildTmuxArgs([
1091
+ "select-window",
1092
+ "-t",
1093
+ paneId
1094
+ ], options), {
1095
+ encoding: "utf8",
1096
+ timeout: 2e3
1097
+ }).catch(() => null);
1098
+ await execFileAsync$1("tmux", buildTmuxArgs([
1099
+ "select-pane",
1100
+ "-t",
1101
+ paneId
1102
+ ], options), {
1103
+ encoding: "utf8",
1104
+ timeout: 2e3
1105
+ }).catch(() => null);
1106
+ };
1107
+ const getPaneGeometry = async (paneId, options) => {
1108
+ try {
1109
+ return parsePaneGeometry((await execFileAsync$1("tmux", buildTmuxArgs([
1110
+ "display-message",
1111
+ "-p",
1112
+ "-t",
1113
+ paneId,
1114
+ "-F",
1115
+ [
1116
+ "#{pane_left}",
1117
+ "#{pane_top}",
1118
+ "#{pane_width}",
1119
+ "#{pane_height}",
1120
+ "#{window_width}",
1121
+ "#{window_height}"
1122
+ ].join(" ")
1123
+ ], options), {
1124
+ encoding: "utf8",
1125
+ timeout: 2e3
1126
+ })).stdout ?? "");
1127
+ } catch {
1128
+ return null;
1129
+ }
1130
+ };
1131
+ const parseBoundsSet = (input) => {
1132
+ const [contentRaw, windowRaw] = input.split("|").map((part) => part.trim());
1133
+ const content = contentRaw ? parseBounds(contentRaw) : null;
1134
+ return {
1135
+ content,
1136
+ window: (windowRaw ? parseBounds(windowRaw) : null) ?? content
1137
+ };
1138
+ };
1139
+ const cropPaneBounds = (base, geometry) => {
1140
+ if (geometry.windowWidth <= 0 || geometry.windowHeight <= 0) return null;
1141
+ const cellWidth = base.width / geometry.windowWidth;
1142
+ const cellHeight = base.height / geometry.windowHeight;
1143
+ const x = Math.round(base.x + geometry.left * cellWidth);
1144
+ const y = Math.round(base.y + geometry.top * cellHeight);
1145
+ const width = Math.round(geometry.width * cellWidth);
1146
+ const height = Math.round(geometry.height * cellHeight);
1147
+ if (width <= 0 || height <= 0) return null;
1148
+ return {
1149
+ x,
1150
+ y,
1151
+ width,
1152
+ height
1153
+ };
1154
+ };
1155
+ const captureTerminalScreen = async (tty, options = {}) => {
1156
+ if (!isMacOS()) return null;
1157
+ if (tty && !isValidTty(tty)) return null;
1158
+ const backend = options.backend ?? "terminal";
1159
+ const candidates = [
1160
+ {
1161
+ key: "alacritty",
1162
+ appName: "Alacritty"
1163
+ },
1164
+ {
1165
+ key: "terminal",
1166
+ appName: "Terminal"
1167
+ },
1168
+ {
1169
+ key: "iterm",
1170
+ appName: "iTerm2"
1171
+ },
1172
+ {
1173
+ key: "wezterm",
1174
+ appName: "WezTerm"
1175
+ },
1176
+ {
1177
+ key: "ghostty",
1178
+ appName: "Ghostty"
1179
+ }
1180
+ ];
1181
+ const isRunning = async (appName) => {
1182
+ return (await runAppleScript(`tell application "System Events" to (exists process "${appName}")`)).trim() === "true";
1183
+ };
1184
+ const app = candidates.find((candidate) => candidate.key === backend) ?? null;
1185
+ if (!app) return null;
1186
+ if (!await isRunning(app.appName)) return null;
1187
+ await focusTerminalApp(app.appName);
1188
+ await wait(200);
1189
+ if (options.paneId) {
1190
+ markPaneFocus(options.paneId);
1191
+ await focusTmuxPane(options.paneId, options.tmux);
1192
+ await wait(200);
1193
+ }
1194
+ const maxAttempts = 3;
1195
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1196
+ const boundsRaw = await runAppleScript(buildTerminalBoundsScript(app.appName));
1197
+ const boundsSet = boundsRaw ? parseBoundsSet(boundsRaw) : {
1198
+ content: null,
1199
+ window: null
1200
+ };
1201
+ const bounds = boundsSet.content ?? boundsSet.window;
1202
+ const paneGeometry = options.cropPane !== false && options.paneId ? await getPaneGeometry(options.paneId, options.tmux) : null;
1203
+ if (bounds) {
1204
+ const croppedBounds = paneGeometry ? cropPaneBounds(bounds, paneGeometry) : null;
1205
+ const imageBase64 = await captureRegion(croppedBounds ?? bounds);
1206
+ if (imageBase64) return {
1207
+ imageBase64,
1208
+ cropped: Boolean(croppedBounds)
1209
+ };
1210
+ }
1211
+ if (attempt < maxAttempts - 1) await wait(200);
1212
+ }
1213
+ return null;
1214
+ };
1215
+
1216
+ //#endregion
1217
+ //#region apps/server/src/app.ts
1218
+ const now = () => (/* @__PURE__ */ new Date()).toISOString();
1219
+ const buildError$1 = (code, message) => ({
1220
+ code,
1221
+ message
1222
+ });
1223
+ const buildEnvelope = (type, data, reqId) => ({
1224
+ type,
1225
+ ts: now(),
1226
+ reqId,
1227
+ data
1228
+ });
1229
+ const createRateLimiter = (windowMs, max) => {
1230
+ const hits = /* @__PURE__ */ new Map();
1231
+ return (key) => {
1232
+ const nowMs = Date.now();
1233
+ const entry = hits.get(key);
1234
+ if (!entry || entry.expiresAt <= nowMs) {
1235
+ hits.set(key, {
1236
+ count: 1,
1237
+ expiresAt: nowMs + windowMs
1238
+ });
1239
+ return true;
1240
+ }
1241
+ if (entry.count >= max) return false;
1242
+ entry.count += 1;
1243
+ return true;
1244
+ };
1245
+ };
1246
+ const createApp = ({ config, monitor, tmuxActions }) => {
1247
+ const app = new Hono();
1248
+ const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
1249
+ const wsClients = /* @__PURE__ */ new Set();
1250
+ const sendLimiter = createRateLimiter(config.rateLimit.send.windowMs, config.rateLimit.send.max);
1251
+ const screenLimiter = createRateLimiter(config.rateLimit.screen.windowMs, config.rateLimit.screen.max);
1252
+ const requireAuth = (c) => {
1253
+ const auth = c.req.header("authorization") ?? c.req.header("Authorization");
1254
+ if (!auth?.startsWith("Bearer ")) return false;
1255
+ return auth.replace("Bearer ", "").trim() === config.token;
1256
+ };
1257
+ const requireStaticAuth = (c) => {
1258
+ const token = c.req.query("token");
1259
+ if (!token) return false;
1260
+ return token === config.token;
1261
+ };
1262
+ const isOriginAllowed = (origin, host) => {
1263
+ if (!origin || config.allowedOrigins.length === 0) return config.allowedOrigins.length === 0 || (host ? config.allowedOrigins.includes(host) : true);
1264
+ return config.allowedOrigins.includes(origin) || (host ? config.allowedOrigins.includes(host) : false);
1265
+ };
1266
+ const sendWs = (ws, message) => {
1267
+ ws.send(JSON.stringify(message));
1268
+ };
1269
+ const broadcast = (message) => {
1270
+ const payload = JSON.stringify(message);
1271
+ wsClients.forEach((ws) => ws.send(payload));
1272
+ };
1273
+ monitor.registry.onChanged((session) => {
1274
+ broadcast(buildEnvelope("session.updated", { session }));
1275
+ });
1276
+ monitor.registry.onRemoved((paneId) => {
1277
+ broadcast(buildEnvelope("session.removed", { paneId }));
1278
+ });
1279
+ app.use("/api/*", async (c, next) => {
1280
+ if (!requireAuth(c)) return c.json({ error: buildError$1("INVALID_PAYLOAD", "unauthorized") }, 401);
1281
+ if (!isOriginAllowed(c.req.header("origin"), c.req.header("host"))) return c.json({ error: buildError$1("INVALID_PAYLOAD", "origin not allowed") }, 403);
1282
+ await next();
1283
+ });
1284
+ app.get("/api/sessions", (c) => {
1285
+ return c.json({
1286
+ sessions: monitor.registry.snapshot(),
1287
+ serverTime: now()
1288
+ });
1289
+ });
1290
+ app.get("/api/sessions/:paneId", (c) => {
1291
+ let paneId;
1292
+ try {
1293
+ paneId = decodePaneId(c.req.param("paneId"));
1294
+ } catch {
1295
+ return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
1296
+ }
1297
+ const detail = monitor.registry.getDetail(paneId);
1298
+ if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
1299
+ return c.json({ session: detail });
1300
+ });
1301
+ app.get("/api/sessions/:paneId/diff", async (c) => {
1302
+ let paneId;
1303
+ try {
1304
+ paneId = decodePaneId(c.req.param("paneId"));
1305
+ } catch {
1306
+ return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
1307
+ }
1308
+ const detail = monitor.registry.getDetail(paneId);
1309
+ if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
1310
+ const force = c.req.query("force") === "1";
1311
+ const summary = await fetchDiffSummary(detail.currentPath, { force });
1312
+ return c.json({ summary });
1313
+ });
1314
+ app.get("/api/sessions/:paneId/diff/file", async (c) => {
1315
+ let paneId;
1316
+ try {
1317
+ paneId = decodePaneId(c.req.param("paneId"));
1318
+ } catch {
1319
+ return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
1320
+ }
1321
+ const detail = monitor.registry.getDetail(paneId);
1322
+ if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
1323
+ const pathParam = c.req.query("path");
1324
+ if (!pathParam) return c.json({ error: buildError$1("INVALID_PAYLOAD", "missing path") }, 400);
1325
+ const force = c.req.query("force") === "1";
1326
+ const summary = await fetchDiffSummary(detail.currentPath, { force });
1327
+ if (!summary.repoRoot || summary.reason || !summary.rev) return c.json({ error: buildError$1("INVALID_PAYLOAD", "diff summary unavailable") }, 400);
1328
+ const target = summary.files.find((file) => file.path === pathParam);
1329
+ if (!target) return c.json({ error: buildError$1("NOT_FOUND", "file not found") }, 404);
1330
+ const file = await fetchDiffFile(summary.repoRoot, target, summary.rev, { force });
1331
+ return c.json({ file });
1332
+ });
1333
+ app.get("/api/sessions/:paneId/commits", async (c) => {
1334
+ let paneId;
1335
+ try {
1336
+ paneId = decodePaneId(c.req.param("paneId"));
1337
+ } catch {
1338
+ return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
1339
+ }
1340
+ const detail = monitor.registry.getDetail(paneId);
1341
+ if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
1342
+ const limit = Number.parseInt(c.req.query("limit") ?? "10", 10);
1343
+ const skip = Number.parseInt(c.req.query("skip") ?? "0", 10);
1344
+ const force = c.req.query("force") === "1";
1345
+ const log = await fetchCommitLog(detail.currentPath, {
1346
+ limit: Number.isFinite(limit) ? limit : 10,
1347
+ skip: Number.isFinite(skip) ? skip : 0,
1348
+ force
1349
+ });
1350
+ return c.json({ log });
1351
+ });
1352
+ app.get("/api/sessions/:paneId/commits/:hash", async (c) => {
1353
+ let paneId;
1354
+ try {
1355
+ paneId = decodePaneId(c.req.param("paneId"));
1356
+ } catch {
1357
+ return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
1358
+ }
1359
+ const detail = monitor.registry.getDetail(paneId);
1360
+ if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
1361
+ const hash = c.req.param("hash");
1362
+ if (!hash) return c.json({ error: buildError$1("INVALID_PAYLOAD", "missing hash") }, 400);
1363
+ const log = await fetchCommitLog(detail.currentPath, {
1364
+ limit: 1,
1365
+ skip: 0
1366
+ });
1367
+ if (!log.repoRoot || log.reason) return c.json({ error: buildError$1("INVALID_PAYLOAD", "commit log unavailable") }, 400);
1368
+ const commit = await fetchCommitDetail(log.repoRoot, hash, { force: c.req.query("force") === "1" });
1369
+ if (!commit) return c.json({ error: buildError$1("NOT_FOUND", "commit not found") }, 404);
1370
+ return c.json({ commit });
1371
+ });
1372
+ app.get("/api/sessions/:paneId/commits/:hash/file", async (c) => {
1373
+ let paneId;
1374
+ try {
1375
+ paneId = decodePaneId(c.req.param("paneId"));
1376
+ } catch {
1377
+ return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
1378
+ }
1379
+ const detail = monitor.registry.getDetail(paneId);
1380
+ if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
1381
+ const hash = c.req.param("hash");
1382
+ const pathParam = c.req.query("path");
1383
+ if (!hash || !pathParam) return c.json({ error: buildError$1("INVALID_PAYLOAD", "missing hash or path") }, 400);
1384
+ const log = await fetchCommitLog(detail.currentPath, {
1385
+ limit: 1,
1386
+ skip: 0
1387
+ });
1388
+ if (!log.repoRoot || log.reason) return c.json({ error: buildError$1("INVALID_PAYLOAD", "commit log unavailable") }, 400);
1389
+ const commit = await fetchCommitDetail(log.repoRoot, hash, { force: true });
1390
+ if (!commit) return c.json({ error: buildError$1("NOT_FOUND", "commit not found") }, 404);
1391
+ const target = commit.files.find((file) => file.path === pathParam) ?? commit.files.find((file) => file.renamedFrom === pathParam);
1392
+ if (!target) return c.json({ error: buildError$1("NOT_FOUND", "file not found") }, 404);
1393
+ const file = await fetchCommitFile(log.repoRoot, hash, target, { force: c.req.query("force") === "1" });
1394
+ return c.json({ file });
1395
+ });
1396
+ app.post("/api/admin/token/rotate", (c) => {
1397
+ const next = rotateToken();
1398
+ config.token = next.token;
1399
+ return c.json({ token: next.token });
1400
+ });
1401
+ const wsHandler = upgradeWebSocket(() => ({
1402
+ onOpen: (_event, ws) => {
1403
+ wsClients.add(ws);
1404
+ sendWs(ws, buildEnvelope("sessions.snapshot", { sessions: monitor.registry.snapshot() }));
1405
+ sendWs(ws, buildEnvelope("server.health", { version: "0.0.1" }));
1406
+ },
1407
+ onClose: (_event, ws) => {
1408
+ wsClients.delete(ws);
1409
+ },
1410
+ onMessage: async (event, ws) => {
1411
+ let parsedJson;
1412
+ try {
1413
+ parsedJson = JSON.parse(event.data.toString());
1414
+ } catch {
1415
+ sendWs(ws, buildEnvelope("command.response", {
1416
+ ok: false,
1417
+ error: buildError$1("INVALID_PAYLOAD", "invalid json")
1418
+ }));
1419
+ return;
1420
+ }
1421
+ const parsed = wsClientMessageSchema.safeParse(parsedJson);
1422
+ if (!parsed.success) {
1423
+ sendWs(ws, buildEnvelope("command.response", {
1424
+ ok: false,
1425
+ error: buildError$1("INVALID_PAYLOAD", "invalid payload")
1426
+ }));
1427
+ return;
1428
+ }
1429
+ const message = parsed.data;
1430
+ const reqId = message.reqId;
1431
+ if (message.type === "client.ping") {
1432
+ sendWs(ws, buildEnvelope("server.health", { version: "0.0.1" }, reqId));
1433
+ return;
1434
+ }
1435
+ const target = monitor.registry.getDetail(message.data.paneId);
1436
+ if (!target) {
1437
+ if (message.type === "screen.request") sendWs(ws, buildEnvelope("screen.response", {
1438
+ ok: false,
1439
+ paneId: message.data.paneId,
1440
+ mode: message.data.mode ?? config.screen.mode,
1441
+ capturedAt: now(),
1442
+ error: buildError$1("NOT_FOUND", "pane not found")
1443
+ }, reqId));
1444
+ else sendWs(ws, buildEnvelope("command.response", {
1445
+ ok: false,
1446
+ error: buildError$1("NOT_FOUND", "pane not found")
1447
+ }, reqId));
1448
+ return;
1449
+ }
1450
+ if (message.type === "screen.request") {
1451
+ if (!screenLimiter("ws")) {
1452
+ sendWs(ws, buildEnvelope("screen.response", {
1453
+ ok: false,
1454
+ paneId: message.data.paneId,
1455
+ mode: "text",
1456
+ capturedAt: now(),
1457
+ error: buildError$1("RATE_LIMIT", "rate limited")
1458
+ }, reqId));
1459
+ return;
1460
+ }
1461
+ const mode = message.data.mode ?? config.screen.mode;
1462
+ const lineCount = Math.min(message.data.lines ?? config.screen.defaultLines, config.screen.maxLines);
1463
+ if (mode === "image") {
1464
+ if (!config.screen.image.enabled) try {
1465
+ const text = await monitor.getScreenCapture().captureText({
1466
+ paneId: message.data.paneId,
1467
+ lines: lineCount,
1468
+ joinLines: config.screen.joinLines,
1469
+ includeAnsi: config.screen.ansi,
1470
+ altScreen: config.screen.altScreen,
1471
+ alternateOn: target.alternateOn
1472
+ });
1473
+ sendWs(ws, buildEnvelope("screen.response", {
1474
+ ok: true,
1475
+ paneId: message.data.paneId,
1476
+ mode: "text",
1477
+ capturedAt: now(),
1478
+ lines: lineCount,
1479
+ truncated: text.truncated,
1480
+ alternateOn: target.alternateOn,
1481
+ screen: text.screen,
1482
+ fallbackReason: "image_disabled"
1483
+ }, reqId));
1484
+ return;
1485
+ } catch {
1486
+ sendWs(ws, buildEnvelope("screen.response", {
1487
+ ok: false,
1488
+ paneId: message.data.paneId,
1489
+ mode: "text",
1490
+ capturedAt: now(),
1491
+ error: buildError$1("INTERNAL", "screen capture failed")
1492
+ }, reqId));
1493
+ return;
1494
+ }
1495
+ const imageResult = await captureTerminalScreen(target.paneTty, {
1496
+ paneId: message.data.paneId,
1497
+ tmux: config.tmux,
1498
+ cropPane: config.screen.image.cropPane,
1499
+ backend: config.screen.image.backend
1500
+ });
1501
+ if (imageResult) {
1502
+ sendWs(ws, buildEnvelope("screen.response", {
1503
+ ok: true,
1504
+ paneId: message.data.paneId,
1505
+ mode: "image",
1506
+ capturedAt: now(),
1507
+ imageBase64: imageResult.imageBase64,
1508
+ cropped: imageResult.cropped
1509
+ }, reqId));
1510
+ return;
1511
+ }
1512
+ try {
1513
+ const text = await monitor.getScreenCapture().captureText({
1514
+ paneId: message.data.paneId,
1515
+ lines: lineCount,
1516
+ joinLines: config.screen.joinLines,
1517
+ includeAnsi: config.screen.ansi,
1518
+ altScreen: config.screen.altScreen,
1519
+ alternateOn: target.alternateOn
1520
+ });
1521
+ sendWs(ws, buildEnvelope("screen.response", {
1522
+ ok: true,
1523
+ paneId: message.data.paneId,
1524
+ mode: "text",
1525
+ capturedAt: now(),
1526
+ lines: lineCount,
1527
+ truncated: text.truncated,
1528
+ alternateOn: target.alternateOn,
1529
+ screen: text.screen,
1530
+ fallbackReason: "image_failed"
1531
+ }, reqId));
1532
+ return;
1533
+ } catch {
1534
+ sendWs(ws, buildEnvelope("screen.response", {
1535
+ ok: false,
1536
+ paneId: message.data.paneId,
1537
+ mode: "text",
1538
+ capturedAt: now(),
1539
+ error: buildError$1("INTERNAL", "screen capture failed")
1540
+ }, reqId));
1541
+ return;
1542
+ }
1543
+ }
1544
+ try {
1545
+ const text = await monitor.getScreenCapture().captureText({
1546
+ paneId: message.data.paneId,
1547
+ lines: lineCount,
1548
+ joinLines: config.screen.joinLines,
1549
+ includeAnsi: config.screen.ansi,
1550
+ altScreen: config.screen.altScreen,
1551
+ alternateOn: target.alternateOn
1552
+ });
1553
+ sendWs(ws, buildEnvelope("screen.response", {
1554
+ ok: true,
1555
+ paneId: message.data.paneId,
1556
+ mode: "text",
1557
+ capturedAt: now(),
1558
+ lines: lineCount,
1559
+ truncated: text.truncated,
1560
+ alternateOn: target.alternateOn,
1561
+ screen: text.screen
1562
+ }, reqId));
1563
+ return;
1564
+ } catch {
1565
+ sendWs(ws, buildEnvelope("screen.response", {
1566
+ ok: false,
1567
+ paneId: message.data.paneId,
1568
+ mode: "text",
1569
+ capturedAt: now(),
1570
+ error: buildError$1("INTERNAL", "screen capture failed")
1571
+ }, reqId));
1572
+ return;
1573
+ }
1574
+ }
1575
+ if (config.readOnly) {
1576
+ sendWs(ws, buildEnvelope("command.response", {
1577
+ ok: false,
1578
+ error: buildError$1("READ_ONLY", "read-only mode")
1579
+ }, reqId));
1580
+ return;
1581
+ }
1582
+ if (!sendLimiter("ws")) {
1583
+ sendWs(ws, buildEnvelope("command.response", {
1584
+ ok: false,
1585
+ error: buildError$1("RATE_LIMIT", "rate limited")
1586
+ }, reqId));
1587
+ return;
1588
+ }
1589
+ if (message.type === "send.text") {
1590
+ sendWs(ws, buildEnvelope("command.response", await tmuxActions.sendText(message.data.paneId, message.data.text, message.data.enter ?? true), reqId));
1591
+ return;
1592
+ }
1593
+ if (message.type === "send.keys") {
1594
+ sendWs(ws, buildEnvelope("command.response", await tmuxActions.sendKeys(message.data.paneId, message.data.keys), reqId));
1595
+ return;
1596
+ }
1597
+ }
1598
+ }));
1599
+ app.use("/ws", async (c, next) => {
1600
+ const token = c.req.query("token");
1601
+ if (!token || token !== config.token) return c.text("Unauthorized", 401);
1602
+ if (!isOriginAllowed(c.req.header("origin"), c.req.header("host"))) return c.text("Forbidden", 403);
1603
+ await next();
1604
+ });
1605
+ app.get("/ws", wsHandler);
1606
+ const distRoot = path.dirname(fileURLToPath(import.meta.url));
1607
+ const bundledDistDir = path.resolve(distRoot, "web");
1608
+ const workspaceDistDir = path.resolve(distRoot, "../../web/dist");
1609
+ const distDir = fs.existsSync(bundledDistDir) ? bundledDistDir : workspaceDistDir;
1610
+ if (fs.existsSync(distDir)) {
1611
+ app.use("/*", async (c, next) => {
1612
+ if (!config.staticAuth) return next();
1613
+ if (!requireStaticAuth(c)) return c.text("Unauthorized", 401);
1614
+ return next();
1615
+ });
1616
+ app.use("/*", serveStatic({ root: distDir }));
1617
+ app.get("/*", serveStatic({
1618
+ root: distDir,
1619
+ path: "index.html"
1620
+ }));
1621
+ }
1622
+ return {
1623
+ app,
1624
+ injectWebSocket
1625
+ };
1626
+ };
1627
+
1628
+ //#endregion
1629
+ //#region packages/agents/src/state-estimator.ts
1630
+ const toTimestamp = (value) => {
1631
+ if (!value) return null;
1632
+ const ts = Date.parse(value);
1633
+ return Number.isNaN(ts) ? null : ts;
1634
+ };
1635
+ const mapHookState = (hookState) => {
1636
+ return {
1637
+ state: hookState.state,
1638
+ reason: hookState.reason
1639
+ };
1640
+ };
1641
+ const estimateState = (signals) => {
1642
+ if (signals.paneDead) return {
1643
+ state: "UNKNOWN",
1644
+ reason: "pane_dead"
1645
+ };
1646
+ if (signals.hookState) return mapHookState(signals.hookState);
1647
+ const lastOutputTs = toTimestamp(signals.lastOutputAt);
1648
+ if (lastOutputTs !== null) {
1649
+ const diff = Date.now() - lastOutputTs;
1650
+ if (diff <= signals.thresholds.runningThresholdMs) return {
1651
+ state: "RUNNING",
1652
+ reason: "recent_output"
1653
+ };
1654
+ if (diff >= signals.thresholds.inactiveThresholdMs) return {
1655
+ state: "WAITING_INPUT",
1656
+ reason: "inactive_timeout"
1657
+ };
1658
+ return {
1659
+ state: "WAITING_INPUT",
1660
+ reason: "recently_inactive"
1661
+ };
1662
+ }
1663
+ return {
1664
+ state: "UNKNOWN",
1665
+ reason: "no_signal"
1666
+ };
1667
+ };
1668
+
1669
+ //#endregion
1670
+ //#region apps/server/src/logs.ts
1671
+ const ensureDir = async (dir) => {
1672
+ await fs$1.mkdir(dir, {
1673
+ recursive: true,
1674
+ mode: 448
1675
+ });
1676
+ };
1677
+ const rotateLogIfNeeded = async (filePath, maxBytes, retainRotations) => {
1678
+ const stat = await fs$1.stat(filePath).catch(() => null);
1679
+ if (!stat || stat.size <= maxBytes) return;
1680
+ const dir = path.dirname(filePath);
1681
+ const base = path.basename(filePath);
1682
+ const rotatedPath = path.join(dir, `${base}.${Date.now()}`);
1683
+ const data = await fs$1.readFile(filePath);
1684
+ await fs$1.writeFile(rotatedPath, data);
1685
+ await fs$1.truncate(filePath, 0);
1686
+ const rotations = (await fs$1.readdir(dir)).filter((name) => name.startsWith(`${base}.`)).map((name) => ({
1687
+ name,
1688
+ fullPath: path.join(dir, name)
1689
+ }));
1690
+ if (rotations.length > retainRotations) {
1691
+ const toDelete = rotations.sort((a, b) => a.name.localeCompare(b.name)).slice(0, rotations.length - retainRotations);
1692
+ await Promise.all(toDelete.map((entry) => fs$1.unlink(entry.fullPath).catch(() => null)));
1693
+ }
1694
+ };
1695
+ const createLogActivityPoller = (pollIntervalMs) => {
1696
+ const entries = /* @__PURE__ */ new Map();
1697
+ const listeners = /* @__PURE__ */ new Set();
1698
+ let timer = null;
1699
+ const register = (paneId, filePath) => {
1700
+ if (!entries.has(filePath)) entries.set(filePath, {
1701
+ paneId,
1702
+ size: 0
1703
+ });
1704
+ };
1705
+ const onActivity = (listener) => {
1706
+ listeners.add(listener);
1707
+ return () => listeners.delete(listener);
1708
+ };
1709
+ const start = () => {
1710
+ if (timer) return;
1711
+ timer = setInterval(async () => {
1712
+ await Promise.all(Array.from(entries.entries()).map(async ([filePath, entry]) => {
1713
+ const stat = await fs$1.stat(filePath).catch(() => null);
1714
+ if (!stat) return;
1715
+ if (stat.size < entry.size) {
1716
+ entry.size = stat.size;
1717
+ return;
1718
+ }
1719
+ if (stat.size > entry.size) {
1720
+ entry.size = stat.size;
1721
+ const at = (/* @__PURE__ */ new Date()).toISOString();
1722
+ listeners.forEach((listener) => listener(entry.paneId, at));
1723
+ }
1724
+ }));
1725
+ }, pollIntervalMs);
1726
+ };
1727
+ const stop = () => {
1728
+ if (timer) {
1729
+ clearInterval(timer);
1730
+ timer = null;
1731
+ }
1732
+ };
1733
+ return {
1734
+ register,
1735
+ onActivity,
1736
+ start,
1737
+ stop
1738
+ };
1739
+ };
1740
+ const createJsonlTailer = (pollIntervalMs) => {
1741
+ let offset = 0;
1742
+ let buffer = "";
1743
+ let timer = null;
1744
+ const listeners = /* @__PURE__ */ new Set();
1745
+ const onLine = (listener) => {
1746
+ listeners.add(listener);
1747
+ return () => listeners.delete(listener);
1748
+ };
1749
+ const start = (filePath) => {
1750
+ if (timer) return;
1751
+ timer = setInterval(async () => {
1752
+ const stat = await fs$1.stat(filePath).catch(() => null);
1753
+ if (!stat) return;
1754
+ if (stat.size < offset) {
1755
+ offset = 0;
1756
+ buffer = "";
1757
+ }
1758
+ if (stat.size === offset) return;
1759
+ const fd = await fs$1.open(filePath, "r");
1760
+ const length = stat.size - offset;
1761
+ const chunk = Buffer.alloc(length);
1762
+ await fd.read(chunk, 0, length, offset);
1763
+ await fd.close();
1764
+ offset = stat.size;
1765
+ buffer += chunk.toString("utf8");
1766
+ const lines = buffer.split("\n");
1767
+ buffer = lines.pop() ?? "";
1768
+ lines.forEach((line) => {
1769
+ if (line.trim().length === 0) return;
1770
+ listeners.forEach((listener) => listener(line));
1771
+ });
1772
+ }, pollIntervalMs);
1773
+ };
1774
+ const stop = () => {
1775
+ if (timer) {
1776
+ clearInterval(timer);
1777
+ timer = null;
1778
+ }
1779
+ };
1780
+ return {
1781
+ onLine,
1782
+ start,
1783
+ stop
1784
+ };
1785
+ };
1786
+
1787
+ //#endregion
1788
+ //#region apps/server/src/session-registry.ts
1789
+ const toSummary = (detail) => {
1790
+ const { startCommand: _startCommand, panePid: _panePid, ...summary } = detail;
1791
+ return summary;
1792
+ };
1793
+ const createSessionRegistry = () => {
1794
+ const sessions = /* @__PURE__ */ new Map();
1795
+ const changeListeners = /* @__PURE__ */ new Set();
1796
+ const removedListeners = /* @__PURE__ */ new Set();
1797
+ const snapshot = () => {
1798
+ return Array.from(sessions.values()).map(toSummary);
1799
+ };
1800
+ const getDetail = (paneId) => {
1801
+ return sessions.get(paneId) ?? null;
1802
+ };
1803
+ const update = (detail) => {
1804
+ const existing = sessions.get(detail.paneId);
1805
+ const next = detail;
1806
+ sessions.set(detail.paneId, next);
1807
+ if (!existing || JSON.stringify(toSummary(existing)) !== JSON.stringify(toSummary(next))) {
1808
+ const summary = toSummary(next);
1809
+ changeListeners.forEach((listener) => listener(summary));
1810
+ }
1811
+ };
1812
+ const removeMissing = (activePaneIds) => {
1813
+ const removed = [];
1814
+ sessions.forEach((_, paneId) => {
1815
+ if (!activePaneIds.has(paneId)) {
1816
+ sessions.delete(paneId);
1817
+ removed.push(paneId);
1818
+ removedListeners.forEach((listener) => listener(paneId));
1819
+ }
1820
+ });
1821
+ return removed;
1822
+ };
1823
+ const onChanged = (listener) => {
1824
+ changeListeners.add(listener);
1825
+ return () => changeListeners.delete(listener);
1826
+ };
1827
+ const onRemoved = (listener) => {
1828
+ removedListeners.add(listener);
1829
+ return () => removedListeners.delete(listener);
1830
+ };
1831
+ const values = () => Array.from(sessions.values());
1832
+ return {
1833
+ snapshot,
1834
+ getDetail,
1835
+ update,
1836
+ removeMissing,
1837
+ onChanged,
1838
+ onRemoved,
1839
+ values
1840
+ };
1841
+ };
1842
+
1843
+ //#endregion
1844
+ //#region apps/server/src/state-store.ts
1845
+ const getStatePath = () => {
1846
+ return path.join(os.homedir(), ".tmux-agent-monitor", "state.json");
1847
+ };
1848
+ const loadState = () => {
1849
+ try {
1850
+ const raw = fs.readFileSync(getStatePath(), "utf8");
1851
+ return JSON.parse(raw);
1852
+ } catch {
1853
+ return null;
1854
+ }
1855
+ };
1856
+ const saveState = (sessions) => {
1857
+ const data = {
1858
+ version: 1,
1859
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
1860
+ sessions: Object.fromEntries(sessions.map((session) => [session.paneId, {
1861
+ paneId: session.paneId,
1862
+ lastOutputAt: session.lastOutputAt,
1863
+ lastEventAt: session.lastEventAt,
1864
+ lastMessage: session.lastMessage,
1865
+ state: session.state,
1866
+ stateReason: session.stateReason
1867
+ }]))
1868
+ };
1869
+ const dir = path.dirname(getStatePath());
1870
+ fs.mkdirSync(dir, {
1871
+ recursive: true,
1872
+ mode: 448
1873
+ });
1874
+ fs.writeFileSync(getStatePath(), `${JSON.stringify(data, null, 2)}\n`, {
1875
+ encoding: "utf8",
1876
+ mode: 384
1877
+ });
1878
+ };
1879
+ const restoreSessions = () => {
1880
+ const state = loadState();
1881
+ if (!state) return /* @__PURE__ */ new Map();
1882
+ return new Map(Object.entries(state.sessions));
1883
+ };
1884
+
1885
+ //#endregion
1886
+ //#region apps/server/src/monitor.ts
1887
+ const baseDir = path.join(os.homedir(), ".tmux-agent-monitor");
1888
+ const execFileAsync = promisify(execFile);
1889
+ const buildAgent = (hint) => {
1890
+ const normalized = hint.toLowerCase();
1891
+ if (normalized.includes("codex")) return "codex";
1892
+ if (normalized.includes("claude")) return "claude";
1893
+ return "unknown";
1894
+ };
1895
+ const mergeHints = (...parts) => parts.filter((part) => Boolean(part && part.trim().length > 0)).join(" ");
1896
+ const processCacheTtlMs = 5e3;
1897
+ const processCommandCache = /* @__PURE__ */ new Map();
1898
+ const ttyAgentCache = /* @__PURE__ */ new Map();
1899
+ const processSnapshotCache = {
1900
+ at: 0,
1901
+ byPid: /* @__PURE__ */ new Map(),
1902
+ children: /* @__PURE__ */ new Map()
1903
+ };
1904
+ const normalizeTty = (tty) => tty.replace(/^\/dev\//, "");
1905
+ const normalizeFingerprint = (text) => text.replace(/\r/g, "").split("\n").map((line) => line.replace(/\s+$/, "")).join("\n").trimEnd();
1906
+ const normalizeTitle = (value) => {
1907
+ if (!value) return null;
1908
+ const trimmed = value.trim();
1909
+ return trimmed.length > 0 ? trimmed : null;
1910
+ };
1911
+ const buildDefaultTitle = (currentPath, paneId, sessionName) => {
1912
+ if (!currentPath) return `${sessionName}:${paneId}`;
1913
+ return `${currentPath.replace(/\/+$/, "").split("/").pop() || "unknown"}:${paneId}`;
1914
+ };
1915
+ const hostCandidates = (() => {
1916
+ const host = os.hostname();
1917
+ const short = host.split(".")[0] ?? host;
1918
+ return new Set([
1919
+ host,
1920
+ short,
1921
+ `${host}.local`,
1922
+ `${short}.local`
1923
+ ]);
1924
+ })();
1925
+ const toIsoFromEpochSeconds = (value) => {
1926
+ if (!value) return null;
1927
+ const date = /* @__PURE__ */ new Date(value * 1e3);
1928
+ if (Number.isNaN(date.getTime())) return null;
1929
+ return date.toISOString();
1930
+ };
1931
+ const getProcessCommand = async (pid) => {
1932
+ if (!pid) return null;
1933
+ const cached = processCommandCache.get(pid);
1934
+ const nowMs = Date.now();
1935
+ if (cached && nowMs - cached.at < processCacheTtlMs) return cached.command;
1936
+ try {
1937
+ const command = ((await execFileAsync("ps", [
1938
+ "-p",
1939
+ String(pid),
1940
+ "-o",
1941
+ "command="
1942
+ ], {
1943
+ encoding: "utf8",
1944
+ timeout: 1e3
1945
+ })).stdout ?? "").trim();
1946
+ if (command.length === 0) return null;
1947
+ processCommandCache.set(pid, {
1948
+ command,
1949
+ at: nowMs
1950
+ });
1951
+ return command;
1952
+ } catch {
1953
+ return null;
1954
+ }
1955
+ };
1956
+ const loadProcessSnapshot = async () => {
1957
+ const nowMs = Date.now();
1958
+ if (nowMs - processSnapshotCache.at < processCacheTtlMs) return processSnapshotCache;
1959
+ try {
1960
+ const result = await execFileAsync("ps", [
1961
+ "-ax",
1962
+ "-o",
1963
+ "pid=,ppid=,command="
1964
+ ], {
1965
+ encoding: "utf8",
1966
+ timeout: 2e3
1967
+ });
1968
+ const byPid = /* @__PURE__ */ new Map();
1969
+ const children = /* @__PURE__ */ new Map();
1970
+ const lines = (result.stdout ?? "").split("\n").filter((line) => line.trim().length > 0);
1971
+ for (const line of lines) {
1972
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
1973
+ if (!match) continue;
1974
+ const pid = Number.parseInt(match[1] ?? "", 10);
1975
+ const ppid = Number.parseInt(match[2] ?? "", 10);
1976
+ if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
1977
+ const command = match[3] ?? "";
1978
+ byPid.set(pid, {
1979
+ pid,
1980
+ ppid,
1981
+ command
1982
+ });
1983
+ const list = children.get(ppid) ?? [];
1984
+ list.push(pid);
1985
+ children.set(ppid, list);
1986
+ }
1987
+ processSnapshotCache.at = nowMs;
1988
+ processSnapshotCache.byPid = byPid;
1989
+ processSnapshotCache.children = children;
1990
+ } catch {}
1991
+ return processSnapshotCache;
1992
+ };
1993
+ const findAgentFromPidTree = async (pid) => {
1994
+ if (!pid) return "unknown";
1995
+ const snapshot = await loadProcessSnapshot();
1996
+ const visited = /* @__PURE__ */ new Set();
1997
+ const stack = [pid];
1998
+ while (stack.length > 0) {
1999
+ const current = stack.pop();
2000
+ if (!current || visited.has(current)) continue;
2001
+ visited.add(current);
2002
+ const entry = snapshot.byPid.get(current);
2003
+ if (entry) {
2004
+ const agent = buildAgent(entry.command);
2005
+ if (agent !== "unknown") return agent;
2006
+ }
2007
+ (snapshot.children.get(current) ?? []).forEach((child) => {
2008
+ if (!visited.has(child)) stack.push(child);
2009
+ });
2010
+ }
2011
+ return "unknown";
2012
+ };
2013
+ const getAgentFromTty = async (tty) => {
2014
+ if (!tty) return "unknown";
2015
+ const normalized = normalizeTty(tty);
2016
+ const cached = ttyAgentCache.get(normalized);
2017
+ const nowMs = Date.now();
2018
+ if (cached && nowMs - cached.at < processCacheTtlMs) return cached.agent;
2019
+ try {
2020
+ const agent = buildAgent(((await execFileAsync("ps", [
2021
+ "-o",
2022
+ "command=",
2023
+ "-t",
2024
+ normalized
2025
+ ], {
2026
+ encoding: "utf8",
2027
+ timeout: 1e3
2028
+ })).stdout ?? "").split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join(" "));
2029
+ ttyAgentCache.set(normalized, {
2030
+ agent,
2031
+ at: nowMs
2032
+ });
2033
+ return agent;
2034
+ } catch {
2035
+ return "unknown";
2036
+ }
2037
+ };
2038
+ const deriveHookState = (hookEventName, notificationType) => {
2039
+ if (hookEventName === "Notification" && notificationType === "permission_prompt") return {
2040
+ state: "WAITING_PERMISSION",
2041
+ reason: "hook:permission_prompt"
2042
+ };
2043
+ if (hookEventName === "Stop") return {
2044
+ state: "WAITING_INPUT",
2045
+ reason: "hook:stop"
2046
+ };
2047
+ if (hookEventName === "UserPromptSubmit" || hookEventName === "PreToolUse" || hookEventName === "PostToolUse") return {
2048
+ state: "RUNNING",
2049
+ reason: `hook:${hookEventName}`
2050
+ };
2051
+ return null;
2052
+ };
2053
+ const mapHookToPane = (panes, hook) => {
2054
+ if (hook.tmux_pane) return hook.tmux_pane;
2055
+ if (hook.tty) {
2056
+ const matches = panes.filter((pane) => pane.paneTty === hook.tty);
2057
+ if (matches.length === 1) return matches[0]?.paneId ?? null;
2058
+ return null;
2059
+ }
2060
+ if (hook.cwd) {
2061
+ const matches = panes.filter((pane) => pane.currentPath === hook.cwd);
2062
+ if (matches.length === 1) return matches[0]?.paneId ?? null;
2063
+ }
2064
+ return null;
2065
+ };
2066
+ const createSessionMonitor = (adapter, config) => {
2067
+ const inspector = createInspector(adapter);
2068
+ const pipeManager = createPipeManager(adapter);
2069
+ const screenCapture = createScreenCapture(adapter);
2070
+ const registry = createSessionRegistry();
2071
+ const hookStates = /* @__PURE__ */ new Map();
2072
+ const lastOutputAt = /* @__PURE__ */ new Map();
2073
+ const lastEventAt = /* @__PURE__ */ new Map();
2074
+ const lastMessage = /* @__PURE__ */ new Map();
2075
+ const lastFingerprint = /* @__PURE__ */ new Map();
2076
+ const restored = restoreSessions();
2077
+ const restoredReason = /* @__PURE__ */ new Set();
2078
+ const serverKey = resolveServerKey(config.tmux.socketName, config.tmux.socketPath);
2079
+ const eventsDir = path.join(baseDir, "events", serverKey);
2080
+ const eventLogPath = path.join(eventsDir, "claude.jsonl");
2081
+ const logActivity = createLogActivityPoller(config.activity.pollIntervalMs);
2082
+ const jsonlTailer = createJsonlTailer(config.activity.pollIntervalMs);
2083
+ let timer = null;
2084
+ restored.forEach((session, paneId) => {
2085
+ lastOutputAt.set(paneId, session.lastOutputAt ?? null);
2086
+ lastEventAt.set(paneId, session.lastEventAt ?? null);
2087
+ lastMessage.set(paneId, session.lastMessage ?? null);
2088
+ });
2089
+ const getPaneLogPath = (paneId) => {
2090
+ return resolveLogPaths(baseDir, serverKey, paneId).paneLogPath;
2091
+ };
2092
+ const ensureLogFiles = async (paneId) => {
2093
+ const { panesDir, paneLogPath } = resolveLogPaths(baseDir, serverKey, paneId);
2094
+ await ensureDir(panesDir);
2095
+ await fs$1.open(paneLogPath, "a").then((handle) => handle.close());
2096
+ };
2097
+ const applyRestored = (paneId) => {
2098
+ if (restored.has(paneId) && !restoredReason.has(paneId)) {
2099
+ restoredReason.add(paneId);
2100
+ return restored.get(paneId) ?? null;
2101
+ }
2102
+ return null;
2103
+ };
2104
+ const capturePaneFingerprint = async (paneId, useAlt) => {
2105
+ const args = [
2106
+ "capture-pane",
2107
+ "-p",
2108
+ "-t",
2109
+ paneId,
2110
+ "-S",
2111
+ "-5",
2112
+ "-E",
2113
+ "-1"
2114
+ ];
2115
+ if (useAlt) args.push("-a");
2116
+ const result = await adapter.run(args);
2117
+ if (result.exitCode !== 0) return null;
2118
+ return normalizeFingerprint(result.stdout ?? "");
2119
+ };
2120
+ const updateFromPanes = async () => {
2121
+ const panes = await inspector.listPanes();
2122
+ const activePaneIds = /* @__PURE__ */ new Set();
2123
+ for (const pane of panes) {
2124
+ if (pane.pipeTagValue === null) pane.pipeTagValue = await inspector.readUserOption(pane.paneId, "@tmux-agent-monitor_pipe");
2125
+ let agent = buildAgent(mergeHints(pane.currentCommand, pane.paneStartCommand, pane.paneTitle));
2126
+ if (agent === "unknown") {
2127
+ const processCommand = await getProcessCommand(pane.panePid);
2128
+ if (processCommand) agent = buildAgent(processCommand);
2129
+ }
2130
+ if (agent === "unknown") agent = await findAgentFromPidTree(pane.panePid);
2131
+ if (agent === "unknown") agent = await getAgentFromTty(pane.paneTty);
2132
+ const monitored = agent !== "unknown";
2133
+ if (!monitored) continue;
2134
+ activePaneIds.add(pane.paneId);
2135
+ const pipeState = {
2136
+ panePipe: pane.panePipe,
2137
+ pipeTagValue: pane.pipeTagValue
2138
+ };
2139
+ let pipeAttached = pane.pipeTagValue === "1";
2140
+ let pipeConflict = pipeManager.hasConflict(pipeState);
2141
+ if (config.attachOnServe && monitored && !pipeConflict) {
2142
+ await ensureLogFiles(pane.paneId);
2143
+ const attachResult = await pipeManager.attachPipe(pane.paneId, getPaneLogPath(pane.paneId), pipeState);
2144
+ pipeAttached = pipeAttached || attachResult.attached;
2145
+ pipeConflict = attachResult.conflict;
2146
+ }
2147
+ if (config.attachOnServe && monitored) logActivity.register(pane.paneId, getPaneLogPath(pane.paneId));
2148
+ await rotateLogIfNeeded(getPaneLogPath(pane.paneId), config.logs.maxPaneLogBytes, config.logs.retainRotations);
2149
+ const hookState = hookStates.get(pane.paneId) ?? null;
2150
+ let outputAt = lastOutputAt.get(pane.paneId) ?? null;
2151
+ const updateOutputAt = (next) => {
2152
+ if (!next) return;
2153
+ const nextTs = Date.parse(next);
2154
+ if (Number.isNaN(nextTs)) return;
2155
+ const prevTs = outputAt ? Date.parse(outputAt) : null;
2156
+ if (!prevTs || Number.isNaN(prevTs) || nextTs > prevTs) {
2157
+ outputAt = new Date(nextTs).toISOString();
2158
+ lastOutputAt.set(pane.paneId, outputAt);
2159
+ }
2160
+ };
2161
+ const logPath = getPaneLogPath(pane.paneId);
2162
+ const stat = await fs$1.stat(logPath).catch(() => null);
2163
+ if (stat && stat.size > 0) updateOutputAt(stat.mtime.toISOString());
2164
+ const windowActivityAt = toIsoFromEpochSeconds(pane.windowActivity);
2165
+ if (windowActivityAt && !shouldSuppressActivity(pane.paneId, windowActivityAt)) updateOutputAt(windowActivityAt);
2166
+ if (agent === "codex" && !pane.paneDead) {
2167
+ const fingerprint = await capturePaneFingerprint(pane.paneId, pane.alternateOn);
2168
+ if (fingerprint) {
2169
+ if (lastFingerprint.get(pane.paneId) !== fingerprint) {
2170
+ lastFingerprint.set(pane.paneId, fingerprint);
2171
+ updateOutputAt((/* @__PURE__ */ new Date()).toISOString());
2172
+ }
2173
+ }
2174
+ }
2175
+ if (!outputAt) updateOutputAt((/* @__PURE__ */ new Date(Date.now() - config.activity.inactiveThresholdMs - 1e3)).toISOString());
2176
+ const eventAt = lastEventAt.get(pane.paneId) ?? null;
2177
+ const message = lastMessage.get(pane.paneId) ?? null;
2178
+ const restoredSession = applyRestored(pane.paneId);
2179
+ const estimated = estimateState({
2180
+ paneDead: pane.paneDead,
2181
+ lastOutputAt: outputAt,
2182
+ hookState,
2183
+ thresholds: {
2184
+ runningThresholdMs: agent === "codex" ? Math.min(config.activity.runningThresholdMs, 1e4) : config.activity.runningThresholdMs,
2185
+ inactiveThresholdMs: config.activity.inactiveThresholdMs
2186
+ }
2187
+ });
2188
+ const finalState = restoredSession ? restoredSession.state : estimated.state;
2189
+ const finalReason = restoredSession ? "restored" : estimated.reason;
2190
+ const paneTitle = normalizeTitle(pane.paneTitle);
2191
+ const defaultTitle = buildDefaultTitle(pane.currentPath, pane.paneId, pane.sessionName);
2192
+ const title = paneTitle && !hostCandidates.has(paneTitle) ? paneTitle : defaultTitle;
2193
+ const detail = {
2194
+ paneId: pane.paneId,
2195
+ sessionName: pane.sessionName,
2196
+ windowIndex: pane.windowIndex,
2197
+ paneIndex: pane.paneIndex,
2198
+ windowActivity: pane.windowActivity,
2199
+ paneActive: pane.paneActive,
2200
+ currentCommand: pane.currentCommand,
2201
+ currentPath: pane.currentPath,
2202
+ paneTty: pane.paneTty,
2203
+ title,
2204
+ agent,
2205
+ state: finalState,
2206
+ stateReason: finalReason,
2207
+ lastMessage: message,
2208
+ lastOutputAt: outputAt,
2209
+ lastEventAt: eventAt,
2210
+ paneDead: pane.paneDead,
2211
+ alternateOn: pane.alternateOn,
2212
+ pipeAttached,
2213
+ pipeConflict,
2214
+ startCommand: pane.paneStartCommand,
2215
+ panePid: pane.panePid
2216
+ };
2217
+ registry.update(detail);
2218
+ }
2219
+ registry.removeMissing(activePaneIds);
2220
+ lastOutputAt.forEach((_, paneId) => {
2221
+ if (!activePaneIds.has(paneId)) {
2222
+ lastOutputAt.delete(paneId);
2223
+ lastEventAt.delete(paneId);
2224
+ lastMessage.delete(paneId);
2225
+ lastFingerprint.delete(paneId);
2226
+ hookStates.delete(paneId);
2227
+ }
2228
+ });
2229
+ saveState(registry.values());
2230
+ };
2231
+ const handleHookEvent = (context) => {
2232
+ hookStates.set(context.paneId, context.hookState);
2233
+ lastEventAt.set(context.paneId, context.hookState.at);
2234
+ };
2235
+ const startHookTailer = async () => {
2236
+ await ensureDir(eventsDir);
2237
+ await fs$1.open(eventLogPath, "a").then((handle) => handle.close());
2238
+ jsonlTailer.onLine((line) => {
2239
+ const parsed = claudeHookEventSchema.safeParse(JSON.parse(line));
2240
+ if (!parsed.success) return;
2241
+ const event = parsed.data;
2242
+ const hookState = deriveHookState(event.hook_event_name, event.notification_type);
2243
+ if (!hookState) return;
2244
+ const paneId = mapHookToPane(registry.values(), {
2245
+ tmux_pane: event.tmux_pane ?? null,
2246
+ tty: event.tty,
2247
+ cwd: event.cwd
2248
+ });
2249
+ if (!paneId) return;
2250
+ handleHookEvent({
2251
+ paneId,
2252
+ hookState: {
2253
+ ...hookState,
2254
+ at: event.ts
2255
+ }
2256
+ });
2257
+ });
2258
+ jsonlTailer.start(eventLogPath);
2259
+ };
2260
+ const start = async () => {
2261
+ logActivity.onActivity((paneId, at) => {
2262
+ lastOutputAt.set(paneId, at);
2263
+ });
2264
+ logActivity.start();
2265
+ await startHookTailer();
2266
+ timer = setInterval(() => {
2267
+ updateFromPanes().catch(() => null);
2268
+ rotateLogIfNeeded(eventLogPath, config.logs.maxEventLogBytes, config.logs.retainRotations).catch(() => null);
2269
+ }, config.activity.pollIntervalMs);
2270
+ await updateFromPanes();
2271
+ };
2272
+ const stop = () => {
2273
+ if (timer) {
2274
+ clearInterval(timer);
2275
+ timer = null;
2276
+ }
2277
+ logActivity.stop();
2278
+ jsonlTailer.stop();
2279
+ };
2280
+ const getScreenCapture = () => screenCapture;
2281
+ return {
2282
+ registry,
2283
+ start,
2284
+ stop,
2285
+ handleHookEvent,
2286
+ getScreenCapture
2287
+ };
2288
+ };
2289
+
2290
+ //#endregion
2291
+ //#region apps/server/src/network.ts
2292
+ const isValidOctets = (parts) => {
2293
+ return parts.every((value) => !Number.isNaN(value) && value >= 0 && value <= 255);
2294
+ };
2295
+ const isPrivateIP = (address) => {
2296
+ const parts = address.split(".").map(Number);
2297
+ if (parts.length !== 4 || !isValidOctets(parts)) return false;
2298
+ const [first, second] = parts;
2299
+ if (first === void 0 || second === void 0) return false;
2300
+ if (first === 10) return true;
2301
+ if (first === 172 && second >= 16 && second <= 31) return true;
2302
+ return first === 192 && second === 168;
2303
+ };
2304
+ const isTailscaleIP = (address) => {
2305
+ const parts = address.split(".").map(Number);
2306
+ if (parts.length !== 4 || !isValidOctets(parts)) return false;
2307
+ const [first, second] = parts;
2308
+ if (first === void 0 || second === void 0) return false;
2309
+ return first === 100 && second >= 64 && second <= 127;
2310
+ };
2311
+ const getTailscaleFromCLI = () => {
2312
+ for (const bin of ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]) try {
2313
+ const ip = execFileSync(bin, ["ip", "-4"], {
2314
+ encoding: "utf8",
2315
+ timeout: 2e3,
2316
+ stdio: [
2317
+ "pipe",
2318
+ "pipe",
2319
+ "ignore"
2320
+ ]
2321
+ }).trim();
2322
+ if (ip && isTailscaleIP(ip)) return ip;
2323
+ } catch {}
2324
+ return null;
2325
+ };
2326
+ const getTailscaleFromInterfaces = () => {
2327
+ const interfaces = networkInterfaces();
2328
+ return Object.values(interfaces).flat().filter((info) => Boolean(info)).find((info) => info.family === "IPv4" && isTailscaleIP(info.address))?.address ?? null;
2329
+ };
2330
+ const getTailscaleIP = () => {
2331
+ return getTailscaleFromCLI() ?? getTailscaleFromInterfaces();
2332
+ };
2333
+ const getLocalIP = () => {
2334
+ const interfaces = networkInterfaces();
2335
+ const candidates = Object.values(interfaces).flat().filter((info) => Boolean(info)).filter((info) => info.family === "IPv4" && !info.internal && !isTailscaleIP(info.address));
2336
+ const privateMatch = candidates.find((info) => isPrivateIP(info.address));
2337
+ if (privateMatch) return privateMatch.address;
2338
+ return candidates[0]?.address ?? "localhost";
2339
+ };
2340
+
2341
+ //#endregion
2342
+ //#region apps/server/src/ports.ts
2343
+ const isPortAvailable = (port, host) => new Promise((resolve) => {
2344
+ const server = createServer();
2345
+ server.once("error", () => {
2346
+ server.close();
2347
+ resolve(false);
2348
+ });
2349
+ server.once("listening", () => {
2350
+ server.close(() => resolve(true));
2351
+ });
2352
+ server.listen(port, host);
2353
+ });
2354
+ const findAvailablePort = async (startPort, host, attempts) => {
2355
+ for (let i = 0; i < attempts; i += 1) {
2356
+ const port = startPort + i;
2357
+ if (await isPortAvailable(port, host)) return port;
2358
+ }
2359
+ throw new Error(`No available port found in range ${startPort}-${startPort + attempts - 1}`);
2360
+ };
2361
+
2362
+ //#endregion
2363
+ //#region apps/server/src/tmux-actions.ts
2364
+ const buildError = (code, message) => ({
2365
+ code,
2366
+ message
2367
+ });
2368
+ const createTmuxActions = (adapter, config) => {
2369
+ const dangerPatterns = compileDangerPatterns(config.dangerCommandPatterns);
2370
+ const enterKey = config.input.enterKey || "C-m";
2371
+ const enterDelayMs = config.input.enterDelayMs ?? 0;
2372
+ const bracketedPaste = (value) => `\u001b[200~${value}\u001b[201~`;
2373
+ const sendText = async (paneId, text, enter = true) => {
2374
+ if (!text || text.trim().length === 0) return {
2375
+ ok: false,
2376
+ error: buildError("INVALID_PAYLOAD", "text is required")
2377
+ };
2378
+ if (text.length > config.input.maxTextLength) return {
2379
+ ok: false,
2380
+ error: buildError("INVALID_PAYLOAD", "text too long")
2381
+ };
2382
+ if (isDangerousCommand(text, dangerPatterns)) return {
2383
+ ok: false,
2384
+ error: buildError("DANGEROUS_COMMAND", "dangerous command blocked")
2385
+ };
2386
+ await adapter.run([
2387
+ "if-shell",
2388
+ "-t",
2389
+ paneId,
2390
+ "[ \"#{pane_in_mode}\" = \"1\" ]",
2391
+ `copy-mode -q -t ${paneId}`
2392
+ ]);
2393
+ const normalized = text.replace(/\r\n/g, "\n");
2394
+ if (normalized.includes("\n")) {
2395
+ const result = await adapter.run([
2396
+ "send-keys",
2397
+ "-l",
2398
+ "-t",
2399
+ paneId,
2400
+ bracketedPaste(normalized)
2401
+ ]);
2402
+ if (result.exitCode !== 0) return {
2403
+ ok: false,
2404
+ error: buildError("INTERNAL", result.stderr || "send-keys failed")
2405
+ };
2406
+ if (enter) {
2407
+ if (enterDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, enterDelayMs));
2408
+ const enterResult = await adapter.run([
2409
+ "send-keys",
2410
+ "-t",
2411
+ paneId,
2412
+ enterKey
2413
+ ]);
2414
+ if (enterResult.exitCode !== 0) return {
2415
+ ok: false,
2416
+ error: buildError("INTERNAL", enterResult.stderr || "send-keys Enter failed")
2417
+ };
2418
+ }
2419
+ return { ok: true };
2420
+ }
2421
+ const result = await adapter.run([
2422
+ "send-keys",
2423
+ "-l",
2424
+ "-t",
2425
+ paneId,
2426
+ normalized
2427
+ ]);
2428
+ if (result.exitCode !== 0) return {
2429
+ ok: false,
2430
+ error: buildError("INTERNAL", result.stderr || "send-keys failed")
2431
+ };
2432
+ if (enter) {
2433
+ if (enterDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, enterDelayMs));
2434
+ const enterResult = await adapter.run([
2435
+ "send-keys",
2436
+ "-t",
2437
+ paneId,
2438
+ enterKey
2439
+ ]);
2440
+ if (enterResult.exitCode !== 0) return {
2441
+ ok: false,
2442
+ error: buildError("INTERNAL", enterResult.stderr || "send-keys Enter failed")
2443
+ };
2444
+ }
2445
+ return { ok: true };
2446
+ };
2447
+ const sendKeys = async (paneId, keys) => {
2448
+ const allowed = new Set(allowedKeys);
2449
+ if (keys.length === 0 || keys.some((key) => !allowed.has(key))) return {
2450
+ ok: false,
2451
+ error: buildError("INVALID_PAYLOAD", "invalid keys")
2452
+ };
2453
+ for (const key of keys) {
2454
+ const result = await adapter.run([
2455
+ "send-keys",
2456
+ "-t",
2457
+ paneId,
2458
+ key
2459
+ ]);
2460
+ if (result.exitCode !== 0) return {
2461
+ ok: false,
2462
+ error: buildError("INTERNAL", result.stderr || "send-keys failed")
2463
+ };
2464
+ }
2465
+ return { ok: true };
2466
+ };
2467
+ return {
2468
+ sendText,
2469
+ sendKeys
2470
+ };
2471
+ };
2472
+
2473
+ //#endregion
2474
+ //#region apps/server/src/index.ts
2475
+ const parseArgs = () => {
2476
+ const args = process.argv.slice(2);
2477
+ const flags = /* @__PURE__ */ new Map();
2478
+ let command = null;
2479
+ const positional = [];
2480
+ for (let i = 0; i < args.length; i += 1) {
2481
+ const arg = args[i];
2482
+ if (!arg) continue;
2483
+ if (arg.startsWith("--")) {
2484
+ const next = args[i + 1];
2485
+ if (next && !next.startsWith("--")) {
2486
+ flags.set(arg, next);
2487
+ i += 1;
2488
+ } else flags.set(arg, true);
2489
+ } else if (!command) command = arg;
2490
+ else positional.push(arg);
2491
+ }
2492
+ return {
2493
+ command,
2494
+ flags,
2495
+ positional
2496
+ };
2497
+ };
2498
+ const printHooksSnippet = () => {
2499
+ console.log(JSON.stringify({ hooks: {
2500
+ PreToolUse: [{
2501
+ matcher: "*",
2502
+ hooks: [{
2503
+ type: "command",
2504
+ command: "tmux-agent-monitor-hook PreToolUse"
2505
+ }]
2506
+ }],
2507
+ PostToolUse: [{
2508
+ matcher: "*",
2509
+ hooks: [{
2510
+ type: "command",
2511
+ command: "tmux-agent-monitor-hook PostToolUse"
2512
+ }]
2513
+ }],
2514
+ Notification: [{ hooks: [{
2515
+ type: "command",
2516
+ command: "tmux-agent-monitor-hook Notification"
2517
+ }] }],
2518
+ Stop: [{ hooks: [{
2519
+ type: "command",
2520
+ command: "tmux-agent-monitor-hook Stop"
2521
+ }] }],
2522
+ UserPromptSubmit: [{ hooks: [{
2523
+ type: "command",
2524
+ command: "tmux-agent-monitor-hook UserPromptSubmit"
2525
+ }] }]
2526
+ } }, null, 2));
2527
+ };
2528
+ const ensureTmuxAvailable = async (adapter) => {
2529
+ if ((await adapter.run(["-V"])).exitCode !== 0) throw new Error("tmux not available");
2530
+ if ((await adapter.run(["list-sessions"])).exitCode !== 0) throw new Error("tmux server not running");
2531
+ };
2532
+ const parsePort = (value) => {
2533
+ if (typeof value !== "string") return null;
2534
+ const parsed = Number.parseInt(value, 10);
2535
+ if (Number.isNaN(parsed) || parsed <= 0) return null;
2536
+ return parsed;
2537
+ };
2538
+ const runServe = async (flags) => {
2539
+ const config = ensureConfig();
2540
+ const publicBind = flags.has("--public");
2541
+ const tailscale = flags.has("--tailscale");
2542
+ const noAttach = flags.has("--no-attach");
2543
+ const portFlag = flags.get("--port");
2544
+ const webPortFlag = flags.get("--web-port");
2545
+ const socketName = flags.get("--socket-name");
2546
+ const socketPath = flags.get("--socket-path");
2547
+ config.bind = publicBind ? "0.0.0.0" : config.bind;
2548
+ config.attachOnServe = !noAttach;
2549
+ const parsedPort = parsePort(portFlag);
2550
+ if (parsedPort) config.port = parsedPort;
2551
+ if (typeof socketName === "string") config.tmux.socketName = socketName;
2552
+ if (typeof socketPath === "string") config.tmux.socketPath = socketPath;
2553
+ const host = config.bind;
2554
+ const port = await findAvailablePort(config.port, host, 10);
2555
+ const adapter = createTmuxAdapter({
2556
+ socketName: config.tmux.socketName,
2557
+ socketPath: config.tmux.socketPath
2558
+ });
2559
+ await ensureTmuxAvailable(adapter);
2560
+ const monitor = createSessionMonitor(adapter, config);
2561
+ await monitor.start();
2562
+ const { app, injectWebSocket } = createApp({
2563
+ config,
2564
+ monitor,
2565
+ tmuxActions: createTmuxActions(adapter, config)
2566
+ });
2567
+ injectWebSocket(serve({
2568
+ fetch: app.fetch,
2569
+ port,
2570
+ hostname: host
2571
+ }));
2572
+ const url = `http://${tailscale ? getTailscaleIP() ?? getLocalIP() : host === "0.0.0.0" ? getLocalIP() : "localhost"}:${parsePort(webPortFlag) ?? port}/?token=${config.token}`;
2573
+ console.log(`tmux-agent-monitor: ${url}`);
2574
+ qrcode.generate(url, { small: true });
2575
+ process.on("SIGINT", () => {
2576
+ monitor.stop();
2577
+ process.exit(0);
2578
+ });
2579
+ };
2580
+ const main = async () => {
2581
+ const { command, positional, flags } = parseArgs();
2582
+ if (command === "token" && positional[0] === "rotate") {
2583
+ const next = rotateToken();
2584
+ console.log(next.token);
2585
+ return;
2586
+ }
2587
+ if (command === "claude" && positional[0] === "hooks" && positional[1] === "print") {
2588
+ printHooksSnippet();
2589
+ return;
2590
+ }
2591
+ await runServe(flags);
2592
+ };
2593
+ main().catch((error) => {
2594
+ console.error(error instanceof Error ? error.message : error);
2595
+ process.exit(1);
2596
+ });
2597
+
2598
+ //#endregion
2599
+ export { };