tmux-agent-monitor 0.0.18 → 0.0.21

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as resolveServerKey, c as allowedKeys, i as resolveLogPaths, l as defaultConfig, n as configSchema, o as compileDangerPatterns, r as wsClientMessageSchema, s as isDangerousCommand, t as claudeHookEventSchema } from "./src-CFxYk-pF.mjs";
2
+ import { a as resolveServerKey, c as allowedKeys, i as resolveLogPaths, l as defaultConfig, n as configSchema, o as compileDangerPatterns, r as wsClientMessageSchema, s as isDangerousCommand, t as claudeHookEventSchema } from "./src-DoQ4vemC.mjs";
3
3
  import { serve } from "@hono/node-server";
4
4
  import { execa } from "execa";
5
5
  import qrcode from "qrcode-terminal";
@@ -8,6 +8,8 @@ import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { serveStatic } from "@hono/node-server/serve-static";
10
10
  import { createNodeWebSocket } from "@hono/node-ws";
11
+ import { zValidator } from "@hono/zod-validator";
12
+ import { z } from "zod";
11
13
  import { Hono } from "hono";
12
14
  import crypto, { randomUUID } from "node:crypto";
13
15
  import os, { networkInterfaces } from "node:os";
@@ -357,7 +359,7 @@ const nowIso$1 = () => (/* @__PURE__ */ new Date()).toISOString();
357
359
  const logCache = /* @__PURE__ */ new Map();
358
360
  const detailCache = /* @__PURE__ */ new Map();
359
361
  const fileCache$1 = /* @__PURE__ */ new Map();
360
- const runGit$1 = async (cwd, args) => {
362
+ const runGit$2 = async (cwd, args) => {
361
363
  try {
362
364
  return (await execFileAsync$3("git", [
363
365
  "-C",
@@ -373,9 +375,9 @@ const runGit$1 = async (cwd, args) => {
373
375
  throw err;
374
376
  }
375
377
  };
376
- const resolveRepoRoot$1 = async (cwd) => {
378
+ const resolveRepoRoot$2 = async (cwd) => {
377
379
  try {
378
- const trimmed = (await runGit$1(cwd, ["rev-parse", "--show-toplevel"])).trim();
380
+ const trimmed = (await runGit$2(cwd, ["rev-parse", "--show-toplevel"])).trim();
379
381
  return trimmed.length > 0 ? trimmed : null;
380
382
  } catch {
381
383
  return null;
@@ -383,7 +385,7 @@ const resolveRepoRoot$1 = async (cwd) => {
383
385
  };
384
386
  const resolveHead = async (repoRoot) => {
385
387
  try {
386
- const trimmed = (await runGit$1(repoRoot, ["rev-parse", "HEAD"])).trim();
388
+ const trimmed = (await runGit$2(repoRoot, ["rev-parse", "HEAD"])).trim();
387
389
  return trimmed.length > 0 ? trimmed : null;
388
390
  } catch {
389
391
  return null;
@@ -497,7 +499,7 @@ const fetchCommitLog = async (cwd, options) => {
497
499
  commits: [],
498
500
  reason: "cwd_unknown"
499
501
  };
500
- const repoRoot = await resolveRepoRoot$1(cwd);
502
+ const repoRoot = await resolveRepoRoot$2(cwd);
501
503
  if (!repoRoot) return {
502
504
  repoRoot: null,
503
505
  rev: null,
@@ -529,7 +531,7 @@ const fetchCommitLog = async (cwd, options) => {
529
531
  FIELD_SEPARATOR,
530
532
  "%b"
531
533
  ].join("");
532
- const commits = parseCommitLogOutput(await runGit$1(repoRoot, [
534
+ const commits = parseCommitLogOutput(await runGit$2(repoRoot, [
533
535
  "log",
534
536
  "-n",
535
537
  String(limit),
@@ -567,7 +569,7 @@ const fetchCommitDetail = async (repoRoot, hash, options) => {
567
569
  const nowMs = Date.now();
568
570
  if (!options?.force && cached && nowMs - cached.at < DETAIL_TTL_MS) return cached.detail;
569
571
  try {
570
- const meta = parseCommitLogOutput(await runGit$1(repoRoot, [
572
+ const meta = parseCommitLogOutput(await runGit$2(repoRoot, [
571
573
  "show",
572
574
  "-s",
573
575
  "--date=iso-strict",
@@ -590,13 +592,13 @@ const fetchCommitDetail = async (repoRoot, hash, options) => {
590
592
  hash
591
593
  ]))[0];
592
594
  if (!meta) return null;
593
- const nameStatusOutput = await runGit$1(repoRoot, [
595
+ const nameStatusOutput = await runGit$2(repoRoot, [
594
596
  "show",
595
597
  "--name-status",
596
598
  "--format=",
597
599
  hash
598
600
  ]);
599
- const numstatOutput = await runGit$1(repoRoot, [
601
+ const numstatOutput = await runGit$2(repoRoot, [
600
602
  "show",
601
603
  "--numstat",
602
604
  "--format=",
@@ -632,7 +634,7 @@ const fetchCommitFile = async (repoRoot, hash, file, options) => {
632
634
  if (!options?.force && cached && nowMs - cached.at < FILE_TTL_MS$1) return cached.file;
633
635
  let patch = "";
634
636
  try {
635
- patch = await runGit$1(repoRoot, [
637
+ patch = await runGit$2(repoRoot, [
636
638
  "show",
637
639
  "--find-renames",
638
640
  "--format=",
@@ -640,7 +642,7 @@ const fetchCommitFile = async (repoRoot, hash, file, options) => {
640
642
  "--",
641
643
  file.path
642
644
  ]);
643
- if (!patch && file.renamedFrom) patch = await runGit$1(repoRoot, [
645
+ if (!patch && file.renamedFrom) patch = await runGit$2(repoRoot, [
644
646
  "show",
645
647
  "--find-renames",
646
648
  "--format=",
@@ -682,7 +684,7 @@ const nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
682
684
  const summaryCache = /* @__PURE__ */ new Map();
683
685
  const fileCache = /* @__PURE__ */ new Map();
684
686
  const createRevision = (statusOutput) => crypto.createHash("sha1").update(statusOutput).digest("hex");
685
- const runGit = async (cwd, args) => {
687
+ const runGit$1 = async (cwd, args) => {
686
688
  try {
687
689
  return (await execFileAsync$2("git", [
688
690
  "-C",
@@ -698,9 +700,9 @@ const runGit = async (cwd, args) => {
698
700
  throw err;
699
701
  }
700
702
  };
701
- const resolveRepoRoot = async (cwd) => {
703
+ const resolveRepoRoot$1 = async (cwd) => {
702
704
  try {
703
- const trimmed = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).trim();
705
+ const trimmed = (await runGit$1(cwd, ["rev-parse", "--show-toplevel"])).trim();
704
706
  return trimmed.length > 0 ? trimmed : null;
705
707
  } catch {
706
708
  return null;
@@ -797,7 +799,7 @@ const fetchDiffSummary = async (cwd, options) => {
797
799
  files: [],
798
800
  reason: "cwd_unknown"
799
801
  };
800
- const repoRoot = await resolveRepoRoot(cwd);
802
+ const repoRoot = await resolveRepoRoot$1(cwd);
801
803
  if (!repoRoot) return {
802
804
  repoRoot: null,
803
805
  rev: null,
@@ -809,13 +811,13 @@ const fetchDiffSummary = async (cwd, options) => {
809
811
  const nowMs = Date.now();
810
812
  if (!options?.force && cached && nowMs - cached.at < SUMMARY_TTL_MS) return cached.summary;
811
813
  try {
812
- const statusOutput = await runGit(repoRoot, [
814
+ const statusOutput = await runGit$1(repoRoot, [
813
815
  "status",
814
816
  "--porcelain",
815
817
  "-z"
816
818
  ]);
817
819
  const files = parseGitStatus(statusOutput);
818
- const stats = parseNumstat(await runGit(repoRoot, [
820
+ const stats = parseNumstat(await runGit$1(repoRoot, [
819
821
  "diff",
820
822
  "HEAD",
821
823
  "--numstat",
@@ -826,7 +828,7 @@ const fetchDiffSummary = async (cwd, options) => {
826
828
  if (file.status !== "?") continue;
827
829
  const safePath = resolveSafePath(repoRoot, file.path);
828
830
  if (!safePath) continue;
829
- const parsed = parseNumstatLine(await runGit(repoRoot, [
831
+ const parsed = parseNumstatLine(await runGit$1(repoRoot, [
830
832
  "diff",
831
833
  "--no-index",
832
834
  "--numstat",
@@ -885,14 +887,14 @@ const fetchDiffFile = async (repoRoot, file, rev, options) => {
885
887
  let numstat = null;
886
888
  try {
887
889
  if (file.status === "?") {
888
- patch = await runGit(repoRoot, [
890
+ patch = await runGit$1(repoRoot, [
889
891
  "diff",
890
892
  "--no-index",
891
893
  "--",
892
894
  "/dev/null",
893
895
  safePath
894
896
  ]);
895
- numstat = parseNumstatLine(await runGit(repoRoot, [
897
+ numstat = parseNumstatLine(await runGit$1(repoRoot, [
896
898
  "diff",
897
899
  "--no-index",
898
900
  "--numstat",
@@ -901,13 +903,13 @@ const fetchDiffFile = async (repoRoot, file, rev, options) => {
901
903
  safePath
902
904
  ]));
903
905
  } else {
904
- patch = await runGit(repoRoot, [
906
+ patch = await runGit$1(repoRoot, [
905
907
  "diff",
906
908
  "HEAD",
907
909
  "--",
908
910
  file.path
909
911
  ]);
910
- numstat = parseNumstatLine(await runGit(repoRoot, [
912
+ numstat = parseNumstatLine(await runGit$1(repoRoot, [
911
913
  "diff",
912
914
  "HEAD",
913
915
  "--numstat",
@@ -1222,129 +1224,108 @@ const buildError$1 = (code, message) => ({
1222
1224
  code,
1223
1225
  message
1224
1226
  });
1227
+ const requireAuth = (config, c) => {
1228
+ const auth = c.req.header("authorization") ?? c.req.header("Authorization");
1229
+ if (!auth?.startsWith("Bearer ")) return false;
1230
+ return auth.replace("Bearer ", "").trim() === config.token;
1231
+ };
1232
+ const isOriginAllowed = (config, origin, host) => {
1233
+ if (config.allowedOrigins.length === 0) return true;
1234
+ if (!origin) return false;
1235
+ return config.allowedOrigins.includes(origin) || (host ? config.allowedOrigins.includes(host) : false);
1236
+ };
1225
1237
  const buildEnvelope = (type, data, reqId) => ({
1226
1238
  type,
1227
1239
  ts: now(),
1228
1240
  reqId,
1229
1241
  data
1230
1242
  });
1231
- const createRateLimiter = (windowMs, max) => {
1232
- const hits = /* @__PURE__ */ new Map();
1233
- return (key) => {
1234
- const nowMs = Date.now();
1235
- const entry = hits.get(key);
1236
- if (!entry || entry.expiresAt <= nowMs) {
1237
- hits.set(key, {
1238
- count: 1,
1239
- expiresAt: nowMs + windowMs
1240
- });
1241
- return true;
1242
- }
1243
- if (entry.count >= max) return false;
1244
- entry.count += 1;
1245
- return true;
1246
- };
1247
- };
1248
- const createApp = ({ config, monitor, tmuxActions }) => {
1249
- const app = new Hono();
1250
- const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
1251
- const wsClients = /* @__PURE__ */ new Set();
1252
- const sendLimiter = createRateLimiter(config.rateLimit.send.windowMs, config.rateLimit.send.max);
1253
- const screenLimiter = createRateLimiter(config.rateLimit.screen.windowMs, config.rateLimit.screen.max);
1254
- const requireAuth = (c) => {
1255
- const auth = c.req.header("authorization") ?? c.req.header("Authorization");
1256
- if (!auth?.startsWith("Bearer ")) return false;
1257
- return auth.replace("Bearer ", "").trim() === config.token;
1258
- };
1259
- const requireStaticAuth = (c) => {
1260
- const token = c.req.query("token");
1261
- if (!token) return false;
1262
- return token === config.token;
1263
- };
1264
- const isOriginAllowed = (origin, host) => {
1265
- if (config.allowedOrigins.length === 0) return true;
1266
- if (!origin) return false;
1267
- return config.allowedOrigins.includes(origin) || (host ? config.allowedOrigins.includes(host) : false);
1268
- };
1269
- const sendWs = (ws, message) => {
1270
- ws.send(JSON.stringify(message));
1271
- };
1272
- const closeAllWsClients = (code, reason) => {
1273
- wsClients.forEach((ws) => {
1274
- try {
1275
- ws.close(code, reason);
1276
- } catch {}
1277
- });
1278
- wsClients.clear();
1279
- };
1280
- const broadcast = (message) => {
1281
- const payload = JSON.stringify(message);
1282
- wsClients.forEach((ws) => ws.send(payload));
1283
- };
1284
- monitor.registry.onChanged((session) => {
1285
- broadcast(buildEnvelope("session.updated", { session }));
1286
- });
1287
- monitor.registry.onRemoved((paneId) => {
1288
- broadcast(buildEnvelope("session.removed", { paneId }));
1289
- });
1290
- app.use("/api/*", async (c, next) => {
1291
- if (!requireAuth(c)) return c.json({ error: buildError$1("INVALID_PAYLOAD", "unauthorized") }, 401);
1292
- if (!isOriginAllowed(c.req.header("origin"), c.req.header("host"))) return c.json({ error: buildError$1("INVALID_PAYLOAD", "origin not allowed") }, 403);
1243
+ const forceQuerySchema = z.object({ force: z.string().optional() });
1244
+ const diffFileQuerySchema = z.object({
1245
+ path: z.string(),
1246
+ rev: z.string().optional(),
1247
+ force: z.string().optional()
1248
+ });
1249
+ const commitLogQuerySchema = z.object({
1250
+ limit: z.string().optional(),
1251
+ skip: z.string().optional(),
1252
+ force: z.string().optional()
1253
+ });
1254
+ const commitDetailQuerySchema = z.object({ force: z.string().optional() });
1255
+ const commitFileQuerySchema = z.object({
1256
+ path: z.string(),
1257
+ force: z.string().optional()
1258
+ });
1259
+ const titleSchema = z.object({ title: z.string().nullable() });
1260
+ const createApiRouter = ({ config, monitor }) => {
1261
+ const api = new Hono();
1262
+ api.use("*", async (c, next) => {
1263
+ if (!requireAuth(config, c)) return c.json({ error: buildError$1("INVALID_PAYLOAD", "unauthorized") }, 401);
1264
+ if (!isOriginAllowed(config, c.req.header("origin"), c.req.header("host"))) return c.json({ error: buildError$1("INVALID_PAYLOAD", "origin not allowed") }, 403);
1293
1265
  await next();
1294
1266
  });
1295
- app.get("/api/sessions", (c) => {
1267
+ return api.get("/sessions", (c) => {
1296
1268
  return c.json({
1297
1269
  sessions: monitor.registry.snapshot(),
1298
1270
  serverTime: now()
1299
1271
  });
1300
- });
1301
- app.get("/api/sessions/:paneId", (c) => {
1272
+ }).get("/sessions/:paneId", (c) => {
1302
1273
  const paneId = c.req.param("paneId");
1303
1274
  if (!paneId) return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
1304
1275
  const detail = monitor.registry.getDetail(paneId);
1305
1276
  if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
1306
1277
  return c.json({ session: detail });
1307
- });
1308
- app.get("/api/sessions/:paneId/diff", async (c) => {
1278
+ }).put("/sessions/:paneId/title", zValidator("json", titleSchema), async (c) => {
1279
+ if (config.readOnly) return c.json({ error: buildError$1("READ_ONLY", "read-only mode") }, 403);
1309
1280
  const paneId = c.req.param("paneId");
1310
1281
  if (!paneId) return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
1311
1282
  const detail = monitor.registry.getDetail(paneId);
1312
1283
  if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
1313
- const force = c.req.query("force") === "1";
1284
+ const { title } = c.req.valid("json");
1285
+ const trimmed = title ? title.trim() : null;
1286
+ if (trimmed && trimmed.length > 80) return c.json({ error: buildError$1("INVALID_PAYLOAD", "title too long") }, 400);
1287
+ const nextTitle = trimmed && trimmed.length > 0 ? trimmed : null;
1288
+ monitor.setCustomTitle(paneId, nextTitle);
1289
+ const updated = monitor.registry.getDetail(paneId) ?? detail;
1290
+ return c.json({ session: updated });
1291
+ }).get("/sessions/:paneId/diff", zValidator("query", forceQuerySchema), async (c) => {
1292
+ const paneId = c.req.param("paneId");
1293
+ if (!paneId) return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
1294
+ const detail = monitor.registry.getDetail(paneId);
1295
+ if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
1296
+ const force = c.req.valid("query").force === "1";
1314
1297
  const summary = await fetchDiffSummary(detail.currentPath, { force });
1315
1298
  return c.json({ summary });
1316
- });
1317
- app.get("/api/sessions/:paneId/diff/file", async (c) => {
1299
+ }).get("/sessions/:paneId/diff/file", zValidator("query", diffFileQuerySchema), async (c) => {
1318
1300
  const paneId = c.req.param("paneId");
1319
1301
  if (!paneId) return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
1320
1302
  const detail = monitor.registry.getDetail(paneId);
1321
1303
  if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
1322
- const pathParam = c.req.query("path");
1323
- if (!pathParam) return c.json({ error: buildError$1("INVALID_PAYLOAD", "missing path") }, 400);
1324
- const force = c.req.query("force") === "1";
1304
+ const query = c.req.valid("query");
1305
+ const pathParam = query.path;
1306
+ const force = query.force === "1";
1325
1307
  const summary = await fetchDiffSummary(detail.currentPath, { force });
1326
1308
  if (!summary.repoRoot || summary.reason || !summary.rev) return c.json({ error: buildError$1("INVALID_PAYLOAD", "diff summary unavailable") }, 400);
1327
1309
  const target = summary.files.find((file) => file.path === pathParam);
1328
1310
  if (!target) return c.json({ error: buildError$1("NOT_FOUND", "file not found") }, 404);
1329
1311
  const file = await fetchDiffFile(summary.repoRoot, target, summary.rev, { force });
1330
1312
  return c.json({ file });
1331
- });
1332
- app.get("/api/sessions/:paneId/commits", async (c) => {
1313
+ }).get("/sessions/:paneId/commits", zValidator("query", commitLogQuerySchema), async (c) => {
1333
1314
  const paneId = c.req.param("paneId");
1334
1315
  if (!paneId) return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
1335
1316
  const detail = monitor.registry.getDetail(paneId);
1336
1317
  if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
1337
- const limit = Number.parseInt(c.req.query("limit") ?? "10", 10);
1338
- const skip = Number.parseInt(c.req.query("skip") ?? "0", 10);
1339
- const force = c.req.query("force") === "1";
1318
+ const query = c.req.valid("query");
1319
+ const limit = Number.parseInt(query.limit ?? "10", 10);
1320
+ const skip = Number.parseInt(query.skip ?? "0", 10);
1321
+ const force = query.force === "1";
1340
1322
  const log = await fetchCommitLog(detail.currentPath, {
1341
1323
  limit: Number.isFinite(limit) ? limit : 10,
1342
1324
  skip: Number.isFinite(skip) ? skip : 0,
1343
1325
  force
1344
1326
  });
1345
1327
  return c.json({ log });
1346
- });
1347
- app.get("/api/sessions/:paneId/commits/:hash", async (c) => {
1328
+ }).get("/sessions/:paneId/commits/:hash", zValidator("query", commitDetailQuerySchema), async (c) => {
1348
1329
  const paneId = c.req.param("paneId");
1349
1330
  if (!paneId) return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
1350
1331
  const detail = monitor.registry.getDetail(paneId);
@@ -1356,18 +1337,19 @@ const createApp = ({ config, monitor, tmuxActions }) => {
1356
1337
  skip: 0
1357
1338
  });
1358
1339
  if (!log.repoRoot || log.reason) return c.json({ error: buildError$1("INVALID_PAYLOAD", "commit log unavailable") }, 400);
1359
- const commit = await fetchCommitDetail(log.repoRoot, hash, { force: c.req.query("force") === "1" });
1340
+ const query = c.req.valid("query");
1341
+ const commit = await fetchCommitDetail(log.repoRoot, hash, { force: query.force === "1" });
1360
1342
  if (!commit) return c.json({ error: buildError$1("NOT_FOUND", "commit not found") }, 404);
1361
1343
  return c.json({ commit });
1362
- });
1363
- app.get("/api/sessions/:paneId/commits/:hash/file", async (c) => {
1344
+ }).get("/sessions/:paneId/commits/:hash/file", zValidator("query", commitFileQuerySchema), async (c) => {
1364
1345
  const paneId = c.req.param("paneId");
1365
1346
  if (!paneId) return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
1366
1347
  const detail = monitor.registry.getDetail(paneId);
1367
1348
  if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
1368
1349
  const hash = c.req.param("hash");
1369
- const pathParam = c.req.query("path");
1370
- if (!hash || !pathParam) return c.json({ error: buildError$1("INVALID_PAYLOAD", "missing hash or path") }, 400);
1350
+ const query = c.req.valid("query");
1351
+ const pathParam = query.path;
1352
+ if (!hash) return c.json({ error: buildError$1("INVALID_PAYLOAD", "missing hash") }, 400);
1371
1353
  const log = await fetchCommitLog(detail.currentPath, {
1372
1354
  limit: 1,
1373
1355
  skip: 0
@@ -1377,9 +1359,69 @@ const createApp = ({ config, monitor, tmuxActions }) => {
1377
1359
  if (!commit) return c.json({ error: buildError$1("NOT_FOUND", "commit not found") }, 404);
1378
1360
  const target = commit.files.find((file) => file.path === pathParam) ?? commit.files.find((file) => file.renamedFrom === pathParam);
1379
1361
  if (!target) return c.json({ error: buildError$1("NOT_FOUND", "file not found") }, 404);
1380
- const file = await fetchCommitFile(log.repoRoot, hash, target, { force: c.req.query("force") === "1" });
1362
+ const file = await fetchCommitFile(log.repoRoot, hash, target, { force: query.force === "1" });
1381
1363
  return c.json({ file });
1382
1364
  });
1365
+ };
1366
+ const createRateLimiter = (windowMs, max) => {
1367
+ const hits = /* @__PURE__ */ new Map();
1368
+ return (key) => {
1369
+ const nowMs = Date.now();
1370
+ const entry = hits.get(key);
1371
+ if (!entry || entry.expiresAt <= nowMs) {
1372
+ hits.set(key, {
1373
+ count: 1,
1374
+ expiresAt: nowMs + windowMs
1375
+ });
1376
+ return true;
1377
+ }
1378
+ if (entry.count >= max) return false;
1379
+ entry.count += 1;
1380
+ return true;
1381
+ };
1382
+ };
1383
+ const createApp = ({ config, monitor, tmuxActions }) => {
1384
+ const app = new Hono();
1385
+ const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
1386
+ const wsClients = /* @__PURE__ */ new Set();
1387
+ const sendLimiter = createRateLimiter(config.rateLimit.send.windowMs, config.rateLimit.send.max);
1388
+ const screenLimiter = createRateLimiter(config.rateLimit.screen.windowMs, config.rateLimit.screen.max);
1389
+ const requireStaticAuth = (c) => {
1390
+ const token = c.req.query("token");
1391
+ if (!token) return false;
1392
+ return token === config.token;
1393
+ };
1394
+ const sendWs = (ws, message) => {
1395
+ ws.send(JSON.stringify(message));
1396
+ };
1397
+ const closeAllWsClients = (code, reason) => {
1398
+ wsClients.forEach((ws) => {
1399
+ try {
1400
+ ws.close(code, reason);
1401
+ } catch {}
1402
+ });
1403
+ wsClients.clear();
1404
+ };
1405
+ const broadcast = (message) => {
1406
+ const payload = JSON.stringify(message);
1407
+ wsClients.forEach((ws) => ws.send(payload));
1408
+ };
1409
+ monitor.registry.onChanged((session) => {
1410
+ broadcast(buildEnvelope("session.updated", { session }));
1411
+ });
1412
+ monitor.registry.onRemoved((paneId) => {
1413
+ broadcast(buildEnvelope("session.removed", { paneId }));
1414
+ });
1415
+ const api = createApiRouter({
1416
+ config,
1417
+ monitor
1418
+ });
1419
+ app.route("/api", api);
1420
+ app.use("/api/admin/*", async (c, next) => {
1421
+ if (!requireAuth(config, c)) return c.json({ error: buildError$1("INVALID_PAYLOAD", "unauthorized") }, 401);
1422
+ if (!isOriginAllowed(config, c.req.header("origin"), c.req.header("host"))) return c.json({ error: buildError$1("INVALID_PAYLOAD", "origin not allowed") }, 403);
1423
+ await next();
1424
+ });
1383
1425
  app.post("/api/admin/token/rotate", (c) => {
1384
1426
  const next = rotateToken();
1385
1427
  config.token = next.token;
@@ -1575,11 +1617,15 @@ const createApp = ({ config, monitor, tmuxActions }) => {
1575
1617
  return;
1576
1618
  }
1577
1619
  if (message.type === "send.text") {
1578
- sendWs(ws, buildEnvelope("command.response", await tmuxActions.sendText(message.data.paneId, message.data.text, message.data.enter ?? true), reqId));
1620
+ const result = await tmuxActions.sendText(message.data.paneId, message.data.text, message.data.enter ?? true);
1621
+ if (result.ok) monitor.recordInput(message.data.paneId);
1622
+ sendWs(ws, buildEnvelope("command.response", result, reqId));
1579
1623
  return;
1580
1624
  }
1581
1625
  if (message.type === "send.keys") {
1582
- sendWs(ws, buildEnvelope("command.response", await tmuxActions.sendKeys(message.data.paneId, message.data.keys), reqId));
1626
+ const result = await tmuxActions.sendKeys(message.data.paneId, message.data.keys);
1627
+ if (result.ok) monitor.recordInput(message.data.paneId);
1628
+ sendWs(ws, buildEnvelope("command.response", result, reqId));
1583
1629
  return;
1584
1630
  }
1585
1631
  }
@@ -1587,7 +1633,7 @@ const createApp = ({ config, monitor, tmuxActions }) => {
1587
1633
  app.use("/ws", async (c, next) => {
1588
1634
  const token = c.req.query("token");
1589
1635
  if (!token || token !== config.token) return c.text("Unauthorized", 401);
1590
- if (!isOriginAllowed(c.req.header("origin"), c.req.header("host"))) return c.text("Forbidden", 403);
1636
+ if (!isOriginAllowed(config, c.req.header("origin"), c.req.header("host"))) return c.text("Forbidden", 403);
1591
1637
  await next();
1592
1638
  });
1593
1639
  app.get("/ws", wsHandler);
@@ -1850,6 +1896,8 @@ const saveState = (sessions) => {
1850
1896
  lastOutputAt: session.lastOutputAt,
1851
1897
  lastEventAt: session.lastEventAt,
1852
1898
  lastMessage: session.lastMessage,
1899
+ lastInputAt: session.lastInputAt,
1900
+ customTitle: session.customTitle ?? null,
1853
1901
  state: session.state,
1854
1902
  stateReason: session.stateReason
1855
1903
  }]))
@@ -1912,6 +1960,8 @@ const hasAgentHint = (value) => Boolean(value && agentHintPattern.test(value));
1912
1960
  const processCacheTtlMs = 5e3;
1913
1961
  const processCommandCache = /* @__PURE__ */ new Map();
1914
1962
  const ttyAgentCache = /* @__PURE__ */ new Map();
1963
+ const repoRootCacheTtlMs = 1e4;
1964
+ const repoRootCache = /* @__PURE__ */ new Map();
1915
1965
  const processSnapshotCache = {
1916
1966
  at: 0,
1917
1967
  byPid: /* @__PURE__ */ new Map(),
@@ -1938,6 +1988,40 @@ const hostCandidates = (() => {
1938
1988
  `${short}.local`
1939
1989
  ]);
1940
1990
  })();
1991
+ const runGit = async (cwd, args) => {
1992
+ try {
1993
+ return (await execFileAsync("git", [
1994
+ "-C",
1995
+ cwd,
1996
+ ...args
1997
+ ], {
1998
+ encoding: "utf8",
1999
+ timeout: 2e3,
2000
+ maxBuffer: 2e6
2001
+ })).stdout ?? "";
2002
+ } catch {
2003
+ return "";
2004
+ }
2005
+ };
2006
+ const resolveRepoRoot = async (cwd) => {
2007
+ if (!cwd) return null;
2008
+ const trimmed = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).trim();
2009
+ return trimmed.length > 0 ? trimmed : null;
2010
+ };
2011
+ const resolveRepoRootCached = async (cwd) => {
2012
+ if (!cwd) return null;
2013
+ const normalized = cwd.replace(/\/+$/, "");
2014
+ if (!normalized) return null;
2015
+ const nowMs = Date.now();
2016
+ const cached = repoRootCache.get(normalized);
2017
+ if (cached && nowMs - cached.at < repoRootCacheTtlMs) return cached.repoRoot;
2018
+ const repoRoot = await resolveRepoRoot(normalized);
2019
+ repoRootCache.set(normalized, {
2020
+ repoRoot,
2021
+ at: nowMs
2022
+ });
2023
+ return repoRoot;
2024
+ };
1941
2025
  const toIsoFromEpochSeconds = (value) => {
1942
2026
  if (!value) return null;
1943
2027
  const date = /* @__PURE__ */ new Date(value * 1e3);
@@ -2088,7 +2172,9 @@ const createSessionMonitor = (adapter, config) => {
2088
2172
  const lastOutputAt = /* @__PURE__ */ new Map();
2089
2173
  const lastEventAt = /* @__PURE__ */ new Map();
2090
2174
  const lastMessage = /* @__PURE__ */ new Map();
2175
+ const lastInputAt = /* @__PURE__ */ new Map();
2091
2176
  const lastFingerprint = /* @__PURE__ */ new Map();
2177
+ const customTitles = /* @__PURE__ */ new Map();
2092
2178
  const restored = restoreSessions();
2093
2179
  const restoredReason = /* @__PURE__ */ new Set();
2094
2180
  const serverKey = resolveServerKey(config.tmux.socketName, config.tmux.socketPath);
@@ -2101,6 +2187,8 @@ const createSessionMonitor = (adapter, config) => {
2101
2187
  lastOutputAt.set(paneId, session.lastOutputAt ?? null);
2102
2188
  lastEventAt.set(paneId, session.lastEventAt ?? null);
2103
2189
  lastMessage.set(paneId, session.lastMessage ?? null);
2190
+ if (session.lastInputAt) lastInputAt.set(paneId, session.lastInputAt);
2191
+ if (session.customTitle) customTitles.set(paneId, session.customTitle);
2104
2192
  });
2105
2193
  const getPaneLogPath = (paneId) => {
2106
2194
  return resolveLogPaths(baseDir, serverKey, paneId).paneLogPath;
@@ -2216,6 +2304,9 @@ const createSessionMonitor = (adapter, config) => {
2216
2304
  const paneTitle = normalizeTitle(pane.paneTitle);
2217
2305
  const defaultTitle = buildDefaultTitle(pane.currentPath, pane.paneId, pane.sessionName);
2218
2306
  const title = paneTitle && !hostCandidates.has(paneTitle) ? paneTitle : defaultTitle;
2307
+ const customTitle = customTitles.get(pane.paneId) ?? null;
2308
+ const repoRoot = await resolveRepoRootCached(pane.currentPath);
2309
+ const inputAt = lastInputAt.get(pane.paneId) ?? null;
2219
2310
  const detail = {
2220
2311
  paneId: pane.paneId,
2221
2312
  sessionName: pane.sessionName,
@@ -2227,12 +2318,15 @@ const createSessionMonitor = (adapter, config) => {
2227
2318
  currentPath: pane.currentPath,
2228
2319
  paneTty: pane.paneTty,
2229
2320
  title,
2321
+ customTitle,
2322
+ repoRoot,
2230
2323
  agent,
2231
2324
  state: finalState,
2232
2325
  stateReason: finalReason,
2233
2326
  lastMessage: message,
2234
2327
  lastOutputAt: outputAt,
2235
2328
  lastEventAt: eventAt,
2329
+ lastInputAt: inputAt,
2236
2330
  paneDead: pane.paneDead,
2237
2331
  alternateOn: pane.alternateOn,
2238
2332
  pipeAttached,
@@ -2242,22 +2336,49 @@ const createSessionMonitor = (adapter, config) => {
2242
2336
  };
2243
2337
  registry.update(detail);
2244
2338
  }
2245
- registry.removeMissing(activePaneIds);
2339
+ registry.removeMissing(activePaneIds).forEach((paneId) => {
2340
+ customTitles.delete(paneId);
2341
+ });
2246
2342
  lastOutputAt.forEach((_, paneId) => {
2247
2343
  if (!activePaneIds.has(paneId)) {
2248
2344
  lastOutputAt.delete(paneId);
2249
2345
  lastEventAt.delete(paneId);
2250
2346
  lastMessage.delete(paneId);
2347
+ lastInputAt.delete(paneId);
2251
2348
  lastFingerprint.delete(paneId);
2252
2349
  hookStates.delete(paneId);
2253
2350
  }
2254
2351
  });
2255
2352
  saveState(registry.values());
2256
2353
  };
2354
+ const setCustomTitle = (paneId, title) => {
2355
+ if (title) customTitles.set(paneId, title);
2356
+ else customTitles.delete(paneId);
2357
+ const existing = registry.getDetail(paneId);
2358
+ if (!existing || existing.customTitle === (title ?? null)) return;
2359
+ const next = {
2360
+ ...existing,
2361
+ customTitle: title
2362
+ };
2363
+ registry.update(next);
2364
+ saveState(registry.values());
2365
+ };
2257
2366
  const handleHookEvent = (context) => {
2258
2367
  hookStates.set(context.paneId, context.hookState);
2259
2368
  lastEventAt.set(context.paneId, context.hookState.at);
2260
2369
  };
2370
+ const recordInput = (paneId, at = (/* @__PURE__ */ new Date()).toISOString()) => {
2371
+ lastInputAt.set(paneId, at);
2372
+ const existing = registry.getDetail(paneId);
2373
+ if (!existing) return;
2374
+ if (existing.lastInputAt === at) return;
2375
+ const next = {
2376
+ ...existing,
2377
+ lastInputAt: at
2378
+ };
2379
+ registry.update(next);
2380
+ saveState(registry.values());
2381
+ };
2261
2382
  const startHookTailer = async () => {
2262
2383
  await ensureDir(eventsDir);
2263
2384
  await fs$1.open(eventLogPath, "a").then((handle) => handle.close());
@@ -2309,7 +2430,9 @@ const createSessionMonitor = (adapter, config) => {
2309
2430
  start,
2310
2431
  stop,
2311
2432
  handleHookEvent,
2312
- getScreenCapture
2433
+ getScreenCapture,
2434
+ setCustomTitle,
2435
+ recordInput
2313
2436
  };
2314
2437
  };
2315
2438