@bondsmith/bondsmithd 0.1.3 → 0.1.5

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.
Files changed (2) hide show
  1. package/dist/index.js +454 -150
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
15
15
  var INVALID_MESSAGE_ERROR = "invalid message";
16
16
 
17
17
  // ../../packages/local-agent-protocol/src/protocol-version.ts
18
- var PROTOCOL_VERSION = 1;
18
+ var PROTOCOL_VERSION = 4;
19
19
 
20
20
  // ../../packages/local-agent-protocol/src/project-path.ts
21
21
  var PROJECT_PATH_INVALID_REASONS = [
@@ -57,6 +57,13 @@ var signedConnectionChallengeSchema = z.object({
57
57
 
58
58
  // ../../packages/local-agent-protocol/src/schemas.ts
59
59
  var MAX_INSERT_TEXT_BYTES = 32 * 1024;
60
+ var MAX_INPUT_CHARS = 16 * 1024;
61
+ var MAX_WIRE_MESSAGE_BYTES = 256 * 1024;
62
+ var MAX_TERMINAL_DIMENSION = 1e3;
63
+ var terminalSizeSchema = z2.object({
64
+ cols: z2.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
65
+ rows: z2.number().int().min(1).max(MAX_TERMINAL_DIMENSION)
66
+ });
60
67
  var localSessionSchema = z2.object({
61
68
  id: z2.string(),
62
69
  title: z2.string(),
@@ -83,6 +90,8 @@ var workspaceFileEntrySchema = z2.object({
83
90
  var workspaceStatusSchema = z2.object({
84
91
  isGitRepo: z2.boolean(),
85
92
  branch: z2.string().nullable(),
93
+ // .catch(undefined): a malformed informational field must never drop the whole status message.
94
+ baseRef: z2.string().nullable().optional().catch(void 0),
86
95
  files: z2.array(workspaceFileEntrySchema)
87
96
  });
88
97
  var workspaceFileDiffSchema = z2.object({
@@ -128,12 +137,15 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
128
137
  // misattributes a daemon error for ANY other in-flight command
129
138
  // (observed: a session.resize racing ahead of its own session.attach)
130
139
  // to the wrong promise — a confirmed bug, not a hypothetical.
131
- requestId: z2.string().min(1).max(200).optional()
140
+ requestId: z2.string().min(1).max(200).optional(),
141
+ // Tolerant like baseRef: an unreadable size must not make the daemon drop the attach itself.
142
+ size: terminalSizeSchema.optional().catch(void 0)
132
143
  }),
144
+ z2.object({ type: z2.literal("session.detach"), sessionId: z2.string() }),
133
145
  z2.object({
134
146
  type: z2.literal("session.input"),
135
147
  sessionId: z2.string(),
136
- data: z2.string()
148
+ data: z2.string().max(MAX_INPUT_CHARS)
137
149
  }),
138
150
  z2.object({
139
151
  type: z2.literal("session.insertText"),
@@ -143,8 +155,7 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
143
155
  z2.object({
144
156
  type: z2.literal("session.resize"),
145
157
  sessionId: z2.string(),
146
- cols: z2.number().int().positive(),
147
- rows: z2.number().int().positive()
158
+ ...terminalSizeSchema.shape
148
159
  }),
149
160
  z2.object({ type: z2.literal("session.stop"), sessionId: z2.string() }),
150
161
  z2.object({
@@ -348,6 +359,7 @@ import { z as z3 } from "zod";
348
359
  var SIGNABLE_ACTIONS = [
349
360
  "session.create",
350
361
  "session.attach",
362
+ "session.detach",
351
363
  "session.input",
352
364
  "session.insertText",
353
365
  "session.resize",
@@ -838,6 +850,8 @@ function expandHome(cwd) {
838
850
  import { execFile as execFileCb } from "child_process";
839
851
  import { promisify } from "util";
840
852
  var execFile = promisify(execFileCb);
853
+ var MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
854
+ var SAFE_DIFF_FLAGS = ["--no-ext-diff", "--no-textconv"];
841
855
  var GitNotARepoError = class extends Error {
842
856
  constructor() {
843
857
  super("not a git repository");
@@ -851,7 +865,7 @@ function isNotARepoError(error2) {
851
865
  }
852
866
  async function runGit(args, cwd) {
853
867
  try {
854
- const { stdout } = await execFile("git", args, { cwd });
868
+ const { stdout } = await execFile("git", args, { cwd, maxBuffer: MAX_GIT_OUTPUT_BYTES });
855
869
  return stdout;
856
870
  } catch (error2) {
857
871
  if (isNotARepoError(error2)) throw new GitNotARepoError();
@@ -860,7 +874,7 @@ async function runGit(args, cwd) {
860
874
  }
861
875
  async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
862
876
  try {
863
- const { stdout } = await execFile("git", args, { cwd });
877
+ const { stdout } = await execFile("git", args, { cwd, maxBuffer: MAX_GIT_OUTPUT_BYTES });
864
878
  return stdout;
865
879
  } catch (error2) {
866
880
  if (isNotARepoError(error2)) throw new GitNotARepoError();
@@ -872,6 +886,12 @@ async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
872
886
  throw error2;
873
887
  }
874
888
  }
889
+ function runGitDiff(args, cwd) {
890
+ return runGit(["diff", ...SAFE_DIFF_FLAGS, ...args], cwd);
891
+ }
892
+ function runGitDiffAllowingExitCodes(args, cwd, allowedExitCodes) {
893
+ return runGitAllowingExitCodes(["diff", ...SAFE_DIFF_FLAGS, ...args], cwd, allowedExitCodes);
894
+ }
875
895
 
876
896
  // src/projects/validate-path.ts
877
897
  var NOT_FOUND_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
@@ -883,6 +903,27 @@ function invalidReasonForFsError(error2) {
883
903
  if (PERMISSION_CODES.has(code)) return "permission_denied";
884
904
  return null;
885
905
  }
906
+ function shellQuote(value) {
907
+ return `'${value.replaceAll("'", `'\\''`)}'`;
908
+ }
909
+ function gitFailure(error2, path) {
910
+ const { code, stderr } = error2;
911
+ if (code === "ENOENT") {
912
+ return new Error(
913
+ "git isn't installed, or isn't on the PATH of the terminal running bondsmithd."
914
+ );
915
+ }
916
+ const text = typeof stderr === "string" ? stderr : "";
917
+ if (text.includes("dubious ownership")) {
918
+ return new Error(
919
+ `git doesn't trust this folder because another user owns it. If you trust it, run: git config --global --add safe.directory ${shellQuote(path)}`
920
+ );
921
+ }
922
+ const fatal = text.split("\n").find((line) => line.startsWith("fatal:"));
923
+ return new Error(
924
+ fatal ?? (error2 instanceof Error ? error2.message : "git failed to inspect that folder.")
925
+ );
926
+ }
886
927
  async function validateProjectPath(input) {
887
928
  const expanded = expandHome(input.trim());
888
929
  if (!isAbsolute(expanded)) return { valid: false, reason: "relative_path" };
@@ -900,7 +941,7 @@ async function validateProjectPath(input) {
900
941
  if (insideWorkTree !== "true") return { valid: false, reason: "not_a_git_repo" };
901
942
  } catch (error2) {
902
943
  if (error2 instanceof GitNotARepoError) return { valid: false, reason: "not_a_git_repo" };
903
- throw error2;
944
+ throw gitFailure(error2, path);
904
945
  }
905
946
  return { valid: true, path };
906
947
  }
@@ -910,7 +951,14 @@ import { sign } from "crypto";
910
951
  import WebSocket from "ws";
911
952
 
912
953
  // src/version.ts
913
- var DAEMON_VERSION = true ? "0.1.3" : "dev";
954
+ var DAEMON_VERSION = true ? "0.1.5" : "dev";
955
+ var VERSION_FLAGS = ["--version", "-v"];
956
+ function versionLine() {
957
+ return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
958
+ }
959
+ function isVersionRequest(argv) {
960
+ return argv.some((arg) => VERSION_FLAGS.includes(arg));
961
+ }
914
962
 
915
963
  // src/sessions/tmux/client.ts
916
964
  import { execFile as execFileCb2 } from "child_process";
@@ -982,9 +1030,18 @@ async function listTmuxSessions(formatString) {
982
1030
  throw error2;
983
1031
  }
984
1032
  }
1033
+ var NO_SERVER_MESSAGE_PATTERNS = [
1034
+ /no server running/i,
1035
+ /no current session/i,
1036
+ /no sessions/i,
1037
+ /failed to connect to server/i,
1038
+ /error connecting to .*\((no such file or directory|connection refused)\)/i
1039
+ ];
985
1040
  function isNoServerError(error2) {
986
- if (typeof error2 !== "object" || error2 === null) return true;
987
- return error2.code !== "ENOENT";
1041
+ if (typeof error2 !== "object" || error2 === null) return false;
1042
+ const { message, stderr } = error2;
1043
+ const text = [message, stderr].filter((part) => typeof part === "string").join("\n");
1044
+ return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(text));
988
1045
  }
989
1046
 
990
1047
  // src/security/verify-command.ts
@@ -1165,6 +1222,76 @@ async function verifyCommand(command, daemonFingerprint, connectionState, truste
1165
1222
  return { ok: true };
1166
1223
  }
1167
1224
 
1225
+ // src/ws/output-pacer.ts
1226
+ var OUTPUT_HIGH_WATER_BYTES = 1024 * 1024;
1227
+ var OUTPUT_LOW_WATER_BYTES = 256 * 1024;
1228
+ var DRAIN_POLL_MS = 50;
1229
+ var MAX_CAPTURE_FAILURES = 5;
1230
+ var REPAINT_PREFIX = "\x1B[0m\x1B[2J\x1B[H";
1231
+ function createOutputPacer(options) {
1232
+ const highWater = options.highWaterBytes ?? OUTPUT_HIGH_WATER_BYTES;
1233
+ const lowWater = options.lowWaterBytes ?? OUTPUT_LOW_WATER_BYTES;
1234
+ const pollMs = options.pollMs ?? DRAIN_POLL_MS;
1235
+ let behind = false;
1236
+ let disposed = false;
1237
+ let captureFailures = 0;
1238
+ let timer;
1239
+ function scheduleRepaint() {
1240
+ timer = setTimeout(() => void repaintWhenDrained(), pollMs);
1241
+ }
1242
+ async function repaintWhenDrained() {
1243
+ timer = void 0;
1244
+ if (disposed) return;
1245
+ if (options.bufferedBytes() > lowWater) {
1246
+ scheduleRepaint();
1247
+ return;
1248
+ }
1249
+ let snapshot;
1250
+ try {
1251
+ snapshot = await options.snapshot();
1252
+ } catch (error2) {
1253
+ if (disposed) return;
1254
+ captureFailures += 1;
1255
+ if (captureFailures < MAX_CAPTURE_FAILURES) {
1256
+ scheduleRepaint();
1257
+ return;
1258
+ }
1259
+ console.warn("bondsmithd: giving up repainting a terminal after repeated capture failures", error2);
1260
+ behind = false;
1261
+ captureFailures = 0;
1262
+ return;
1263
+ }
1264
+ if (disposed) return;
1265
+ captureFailures = 0;
1266
+ if (options.bufferedBytes() > lowWater) {
1267
+ scheduleRepaint();
1268
+ return;
1269
+ }
1270
+ try {
1271
+ options.send(REPAINT_PREFIX + snapshot);
1272
+ } catch (error2) {
1273
+ console.error("bondsmithd: terminal repaint failed", error2);
1274
+ }
1275
+ behind = false;
1276
+ }
1277
+ return {
1278
+ write(data) {
1279
+ if (disposed || behind) return;
1280
+ if (options.bufferedBytes() > highWater) {
1281
+ behind = true;
1282
+ scheduleRepaint();
1283
+ return;
1284
+ }
1285
+ options.send(data);
1286
+ },
1287
+ dispose() {
1288
+ disposed = true;
1289
+ if (timer !== void 0) clearTimeout(timer);
1290
+ timer = void 0;
1291
+ }
1292
+ };
1293
+ }
1294
+
1168
1295
  // src/ws/router.ts
1169
1296
  var SIGNABLE_ACTION_SET = new Set(SIGNABLE_ACTIONS);
1170
1297
  function isSignableAction(type) {
@@ -1191,17 +1318,31 @@ function verifyRejectionMessage(reason) {
1191
1318
  var REVOKED_REJECTION_MESSAGE = "connection has not completed its handshake";
1192
1319
  var BRACKETED_PASTE_START = "\x1B[200~";
1193
1320
  var BRACKETED_PASTE_END = "\x1B[201~";
1194
- function createRouterConnection(connectionAuth, send, markControllerId, ptyAttachments) {
1321
+ function releaseSession(conn, ptyAttachments, sessionId) {
1322
+ const listener = conn.getOutputListener(sessionId);
1323
+ if (listener) ptyAttachments.release(sessionId, listener);
1324
+ conn.unregisterOutputListener(sessionId);
1325
+ conn.subscriptions.delete(sessionId);
1326
+ }
1327
+ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttachments, bufferedBytes) {
1195
1328
  const outputListeners = /* @__PURE__ */ new Map();
1196
1329
  const attachLocks = /* @__PURE__ */ new Map();
1330
+ let closed = false;
1197
1331
  let admissionChain = Promise.resolve();
1198
1332
  const conn = {
1199
1333
  subscriptions: /* @__PURE__ */ new Set(),
1200
1334
  send,
1201
- registerOutputListener: (sessionId, listener) => {
1202
- outputListeners.set(sessionId, listener);
1335
+ bufferedBytes,
1336
+ isClosed: () => closed,
1337
+ registerOutputListener: (sessionId, listener, dispose) => {
1338
+ outputListeners.get(sessionId)?.dispose?.();
1339
+ outputListeners.set(sessionId, { listener, dispose });
1340
+ },
1341
+ getOutputListener: (sessionId) => outputListeners.get(sessionId)?.listener,
1342
+ unregisterOutputListener: (sessionId) => {
1343
+ outputListeners.get(sessionId)?.dispose?.();
1344
+ outputListeners.delete(sessionId);
1203
1345
  },
1204
- getOutputListener: (sessionId) => outputListeners.get(sessionId),
1205
1346
  connectionAuth,
1206
1347
  markControllerId,
1207
1348
  admitInOrder: (fn) => {
@@ -1224,9 +1365,9 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
1224
1365
  }
1225
1366
  };
1226
1367
  function releaseAllSubscriptions() {
1227
- for (const sessionId of conn.subscriptions) {
1228
- const listener = outputListeners.get(sessionId);
1229
- if (listener) ptyAttachments.release(sessionId, listener);
1368
+ closed = true;
1369
+ for (const sessionId of [...conn.subscriptions]) {
1370
+ releaseSession(conn, ptyAttachments, sessionId);
1230
1371
  }
1231
1372
  }
1232
1373
  return { conn, releaseAllSubscriptions };
@@ -1237,9 +1378,12 @@ function error(message, requestId) {
1237
1378
  function errorMessageOf(err, fallback) {
1238
1379
  return err instanceof Error ? err.message : fallback;
1239
1380
  }
1240
- function getSessionOrError(sessionManager, conn, sessionId) {
1381
+ function requestIdOf(message) {
1382
+ return "requestId" in message ? message.requestId : void 0;
1383
+ }
1384
+ function getSessionOrError(sessionManager, conn, sessionId, requestId) {
1241
1385
  const session = sessionManager.get(sessionId);
1242
- if (!session) conn.send(error("unknown session"));
1386
+ if (!session) conn.send(error("unknown session", requestId));
1243
1387
  return session;
1244
1388
  }
1245
1389
  async function handleBrowserMessage(conn, raw, deps) {
@@ -1259,11 +1403,11 @@ async function handleBrowserMessage(conn, raw, deps) {
1259
1403
  deps.trustedControllersPath
1260
1404
  );
1261
1405
  if (!verdict.ok) {
1262
- conn.send(error(verifyRejectionMessage(verdict.reason)));
1406
+ conn.send(error(verifyRejectionMessage(verdict.reason), requestIdOf(innerMessage.data)));
1263
1407
  return false;
1264
1408
  }
1265
1409
  if (conn.connectionAuth.isRevoked()) {
1266
- conn.send(error(REVOKED_REJECTION_MESSAGE));
1410
+ conn.send(error(REVOKED_REJECTION_MESSAGE, requestIdOf(innerMessage.data)));
1267
1411
  return false;
1268
1412
  }
1269
1413
  return true;
@@ -1291,7 +1435,7 @@ async function handleBrowserMessage(conn, raw, deps) {
1291
1435
  return;
1292
1436
  }
1293
1437
  if (isSignableAction(parsed.data.type)) {
1294
- conn.send(error("signature required for this command"));
1438
+ conn.send(error("signature required for this command", requestIdOf(parsed.data)));
1295
1439
  return;
1296
1440
  }
1297
1441
  await dispatch(conn, parsed.data, deps);
@@ -1336,8 +1480,17 @@ async function dispatch(conn, message, {
1336
1480
  }
1337
1481
  await conn.withAttachLock(message.sessionId, async () => {
1338
1482
  const previousListener = conn.getOutputListener(message.sessionId);
1339
- const onData = (data) => {
1340
- conn.send({ type: "terminal.output", sessionId: message.sessionId, data });
1483
+ const output = createOutputPacer({
1484
+ send: (data) => conn.send({ type: "terminal.output", sessionId: message.sessionId, data }),
1485
+ bufferedBytes: conn.bufferedBytes,
1486
+ snapshot: () => ptyAttachments.paneSnapshot(session.tmuxSessionName)
1487
+ });
1488
+ const onData = output.write;
1489
+ const abandonedBecauseClosed = () => {
1490
+ if (!conn.isClosed()) return false;
1491
+ ptyAttachments.release(message.sessionId, onData);
1492
+ output.dispose();
1493
+ return true;
1341
1494
  };
1342
1495
  if (previousListener && await ptyAttachments.swapListener(
1343
1496
  message.sessionId,
@@ -1345,29 +1498,41 @@ async function dispatch(conn, message, {
1345
1498
  previousListener,
1346
1499
  onData
1347
1500
  )) {
1501
+ if (abandonedBecauseClosed()) return;
1348
1502
  conn.subscriptions.add(message.sessionId);
1349
- conn.registerOutputListener(message.sessionId, onData);
1503
+ conn.registerOutputListener(message.sessionId, onData, output.dispose);
1350
1504
  conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
1351
1505
  return;
1352
1506
  }
1353
1507
  if (previousListener) {
1354
1508
  ptyAttachments.release(message.sessionId, previousListener);
1509
+ conn.unregisterOutputListener(message.sessionId);
1355
1510
  }
1356
1511
  const attached = await ptyAttachments.ensureAttached(
1357
1512
  message.sessionId,
1358
1513
  session.tmuxSessionName,
1359
- onData
1514
+ onData,
1515
+ message.size
1360
1516
  );
1361
1517
  if (!attached) {
1518
+ output.dispose();
1362
1519
  conn.send(error("failed to attach to session", requestId));
1363
1520
  return;
1364
1521
  }
1522
+ if (abandonedBecauseClosed()) return;
1365
1523
  conn.subscriptions.add(message.sessionId);
1366
- conn.registerOutputListener(message.sessionId, onData);
1524
+ conn.registerOutputListener(message.sessionId, onData, output.dispose);
1367
1525
  conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
1368
1526
  });
1369
1527
  return;
1370
1528
  }
1529
+ case "session.detach": {
1530
+ if (!isValidSessionId(message.sessionId)) return;
1531
+ await conn.withAttachLock(message.sessionId, async () => {
1532
+ releaseSession(conn, ptyAttachments, message.sessionId);
1533
+ });
1534
+ return;
1535
+ }
1371
1536
  case "session.input": {
1372
1537
  if (!conn.subscriptions.has(message.sessionId)) {
1373
1538
  conn.send(error("not attached to session"));
@@ -1409,7 +1574,7 @@ async function dispatch(conn, message, {
1409
1574
  return;
1410
1575
  }
1411
1576
  case "workspace.getStatus": {
1412
- const session = getSessionOrError(sessionManager, conn, message.sessionId);
1577
+ const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
1413
1578
  if (!session) return;
1414
1579
  try {
1415
1580
  const status = await getWorkspaceStatus2(session.cwd);
@@ -1420,12 +1585,12 @@ async function dispatch(conn, message, {
1420
1585
  status
1421
1586
  });
1422
1587
  } catch (err) {
1423
- conn.send(error(errorMessageOf(err, "failed to read workspace status")));
1588
+ conn.send(error(errorMessageOf(err, "failed to read workspace status"), message.requestId));
1424
1589
  }
1425
1590
  return;
1426
1591
  }
1427
1592
  case "workspace.getDiff": {
1428
- const session = getSessionOrError(sessionManager, conn, message.sessionId);
1593
+ const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
1429
1594
  if (!session) return;
1430
1595
  try {
1431
1596
  const diff = await getWorkspaceDiff2(session.cwd, message.path, message.staged);
@@ -1436,7 +1601,7 @@ async function dispatch(conn, message, {
1436
1601
  diff
1437
1602
  });
1438
1603
  } catch (err) {
1439
- conn.send(error(errorMessageOf(err, "failed to read workspace diff")));
1604
+ conn.send(error(errorMessageOf(err, "failed to read workspace diff"), message.requestId));
1440
1605
  }
1441
1606
  return;
1442
1607
  }
@@ -1771,7 +1936,9 @@ function startRelayClient(deps) {
1771
1936
  },
1772
1937
  () => {
1773
1938
  },
1774
- deps.ptyAttachments
1939
+ deps.ptyAttachments,
1940
+ // One socket carries every browser session, so a backed-up relay link slows all of them together.
1941
+ () => ws.bufferedAmount
1775
1942
  );
1776
1943
  }
1777
1944
  function handleSessionOpened(ws, relaySessionId) {
@@ -1999,12 +2166,27 @@ async function persistToken(tokenPath, token) {
1999
2166
 
2000
2167
  // src/security/pending-authorizations.ts
2001
2168
  import { createHash, randomUUID } from "crypto";
2169
+
2170
+ // src/security/controller-label.ts
2171
+ var MAX_LABEL_CHARACTERS = 60;
2172
+ var MAX_STACKED_MARKS = 3;
2173
+ var FALLBACK_LABEL = "Unnamed browser";
2174
+ var EXCESS_MARKS = new RegExp(`(\\p{M}{${MAX_STACKED_MARKS}})\\p{M}+`, "gu");
2175
+ function sanitizeControllerLabel(raw) {
2176
+ const printable = raw.replace(/\p{C}/gu, " ").replace(EXCESS_MARKS, "$1").replace(/\s+/g, " ").trim();
2177
+ const clipped = Array.from(printable).slice(0, MAX_LABEL_CHARACTERS).join("").trim();
2178
+ return clipped === "" ? FALLBACK_LABEL : clipped;
2179
+ }
2180
+
2181
+ // src/security/pending-authorizations.ts
2002
2182
  function controllerIdFromPublicKeySync(publicKeyPem) {
2003
2183
  return createHash("sha256").update(publicKeyPem).digest("hex").slice(0, 32);
2004
2184
  }
2005
2185
  var REQUEST_EXPIRY_MS = 5 * 6e4;
2006
2186
  var MAX_REQUESTS_PER_WINDOW = 5;
2007
2187
  var RATE_LIMIT_WINDOW_MS = 6e4;
2188
+ var MAX_PENDING_REQUESTS = 5;
2189
+ var MAX_NEW_REQUESTS_PER_WINDOW = 10;
2008
2190
  var PendingAuthorizationStore = class {
2009
2191
  // Invariant: at most one PendingEntry per controllerId at any time. A
2010
2192
  // conflicting requestEnrollment call for a controllerId that already has
@@ -2015,6 +2197,7 @@ var PendingAuthorizationStore = class {
2015
2197
  // resolve a newer, unrelated one.
2016
2198
  pending = /* @__PURE__ */ new Map();
2017
2199
  requestTimestamps = /* @__PURE__ */ new Map();
2200
+ allRequestTimestamps = [];
2018
2201
  isInteractive;
2019
2202
  trustedControllersPath;
2020
2203
  constructor(options = {}) {
@@ -2060,7 +2243,7 @@ var PendingAuthorizationStore = class {
2060
2243
  result: existing.resultPromise
2061
2244
  };
2062
2245
  }
2063
- if (this.isRateLimited(controllerId)) {
2246
+ if (this.isRateLimited(controllerId) || this.isOverGlobalLimits()) {
2064
2247
  return { ok: false, reason: "rate_limited" };
2065
2248
  }
2066
2249
  this.recordRequestTimestamp(controllerId);
@@ -2068,7 +2251,7 @@ var PendingAuthorizationStore = class {
2068
2251
  const authorization = {
2069
2252
  controllerId,
2070
2253
  publicKeyPem: params.publicKeyPem,
2071
- label: params.label,
2254
+ label: sanitizeControllerLabel(params.label),
2072
2255
  fingerprint: "",
2073
2256
  requestedAt: now,
2074
2257
  expiresAt: now + REQUEST_EXPIRY_MS,
@@ -2156,19 +2339,33 @@ var PendingAuthorizationStore = class {
2156
2339
  entry.settle("cancelled");
2157
2340
  }
2158
2341
  }
2342
+ /** True while this exact request is still waiting for an answer, so a queued prompt can skip one that expired or was cancelled. */
2343
+ isPending(controllerId, requestToken) {
2344
+ const entry = this.pending.get(controllerId);
2345
+ return entry !== void 0 && !entry.settled && entry.authorization.requestToken === requestToken && entry.authorization.expiresAt > Date.now();
2346
+ }
2159
2347
  isRateLimited(controllerId) {
2160
2348
  const timestamps = this.requestTimestamps.get(controllerId) ?? [];
2161
2349
  const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
2162
2350
  const recent = timestamps.filter((t) => t > windowStart);
2163
2351
  return recent.length >= MAX_REQUESTS_PER_WINDOW;
2164
2352
  }
2165
- recordRequestTimestamp(controllerId) {
2353
+ isOverGlobalLimits() {
2166
2354
  const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
2167
- const timestamps = (this.requestTimestamps.get(controllerId) ?? []).filter(
2168
- (t) => t > windowStart
2169
- );
2170
- timestamps.push(Date.now());
2171
- this.requestTimestamps.set(controllerId, timestamps);
2355
+ const recent = this.allRequestTimestamps.filter((t) => t > windowStart);
2356
+ return recent.length >= MAX_NEW_REQUESTS_PER_WINDOW || this.pending.size >= MAX_PENDING_REQUESTS;
2357
+ }
2358
+ recordRequestTimestamp(controllerId) {
2359
+ const now = Date.now();
2360
+ const windowStart = now - RATE_LIMIT_WINDOW_MS;
2361
+ this.allRequestTimestamps = this.allRequestTimestamps.filter((t) => t > windowStart);
2362
+ this.allRequestTimestamps.push(now);
2363
+ for (const [key, timestamps] of this.requestTimestamps) {
2364
+ if (!timestamps.some((t) => t > windowStart)) this.requestTimestamps.delete(key);
2365
+ }
2366
+ const recent = (this.requestTimestamps.get(controllerId) ?? []).filter((t) => t > windowStart);
2367
+ recent.push(now);
2368
+ this.requestTimestamps.set(controllerId, recent);
2172
2369
  }
2173
2370
  };
2174
2371
 
@@ -2280,42 +2477,101 @@ function startRevocationSync(deps) {
2280
2477
  // src/security/terminal-approval.ts
2281
2478
  import { createInterface } from "readline";
2282
2479
  function presentPendingAuthorizationOnTerminal(store) {
2283
- return (pending) => {
2480
+ const waiting = [];
2481
+ let prompting = false;
2482
+ let inputEnded = false;
2483
+ const record = (pending, approved) => {
2484
+ store.resolve(pending.controllerId, pending.requestToken, approved).catch((error2) => {
2485
+ console.error("Failed to record controller authorization decision", error2);
2486
+ });
2487
+ };
2488
+ const askNext = () => {
2489
+ if (prompting) return;
2490
+ const pending = waiting.shift();
2491
+ if (!pending) return;
2492
+ if (!store.isPending(pending.controllerId, pending.requestToken)) {
2493
+ askNext();
2494
+ return;
2495
+ }
2496
+ if (inputEnded) {
2497
+ record(pending, false);
2498
+ askNext();
2499
+ return;
2500
+ }
2501
+ prompting = true;
2284
2502
  console.log("");
2285
2503
  console.log(" Bondsmith controller authorization request");
2286
- console.log(` Browser: ${pending.label}`);
2504
+ console.log(` Browser: "${pending.label}"`);
2287
2505
  console.log(` Fingerprint: ${pending.fingerprint}`);
2506
+ if (waiting.length > 0) console.log(` (${waiting.length} more waiting after this one)`);
2288
2507
  console.log("");
2289
2508
  console.log(" Allow this browser to control local sessions? [y/N]");
2290
2509
  const rl = createInterface({ input: process.stdin, output: process.stdout });
2291
- rl.question(" > ", (answer) => {
2510
+ let answered = false;
2511
+ let expired = false;
2512
+ const expiryTimer = setTimeout(
2513
+ () => {
2514
+ expired = true;
2515
+ rl.close();
2516
+ },
2517
+ Math.max(0, pending.expiresAt - Date.now())
2518
+ );
2519
+ const finish = (approved) => {
2520
+ if (answered) return;
2521
+ answered = true;
2522
+ clearTimeout(expiryTimer);
2292
2523
  rl.close();
2293
- const approved = answer.trim().toLowerCase() === "y";
2294
- store.resolve(pending.controllerId, pending.requestToken, approved).catch((error2) => {
2295
- console.error("Failed to record controller authorization decision", error2);
2296
- });
2297
- console.log(approved ? " Approved." : " Denied.");
2524
+ record(pending, approved);
2525
+ if (expired) console.log(" Request expired.");
2526
+ else console.log(approved ? " Approved." : " Denied.");
2298
2527
  console.log("");
2528
+ prompting = false;
2529
+ askNext();
2530
+ };
2531
+ rl.question(" > ", (answer) => finish(answer.trim().toLowerCase() === "y"));
2532
+ rl.on("SIGINT", () => finish(false));
2533
+ rl.on("close", () => {
2534
+ if (!answered && !expired) inputEnded = true;
2535
+ finish(false);
2299
2536
  });
2300
2537
  };
2538
+ return (pending) => {
2539
+ waiting.push(pending);
2540
+ askNext();
2541
+ };
2301
2542
  }
2302
2543
 
2303
- // src/sessions/git/diff.ts
2304
- async function getWorkspaceDiff(cwd, path, staged) {
2305
- const numstatArgs = staged ? ["diff", "--cached", "--numstat", "--", path] : ["diff", "--numstat", "--", path];
2306
- const numstat = await runGit(numstatArgs, cwd);
2307
- const binary = /^(-\t-\t)/.test(numstat.trim());
2308
- if (binary) {
2309
- return { path, staged, patch: null, binary: true };
2544
+ // src/sessions/git/base.ts
2545
+ var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
2546
+ var DEFAULT_BRANCH_CANDIDATES = ["origin/main", "origin/master", "main", "master"];
2547
+ async function tryGit(args, cwd) {
2548
+ try {
2549
+ return (await runGit(args, cwd)).trim() || null;
2550
+ } catch {
2551
+ return null;
2310
2552
  }
2311
- const diffArgs = staged ? ["diff", "--cached", "--", path] : ["diff", "--", path];
2312
- let patch = await runGit(diffArgs, cwd);
2313
- if (patch.trim() === "" && !staged) {
2314
- patch = await runGitAllowingExitCodes(["diff", "--no-index", "--", "/dev/null", path], cwd, [
2315
- 1
2316
- ]).catch(() => "");
2553
+ }
2554
+ async function findDefaultBranchRef(cwd) {
2555
+ const remoteHead = await tryGit(
2556
+ ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
2557
+ cwd
2558
+ );
2559
+ if (remoteHead) return remoteHead;
2560
+ for (const candidate of DEFAULT_BRANCH_CANDIDATES) {
2561
+ if (await tryGit(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], cwd)) {
2562
+ return candidate;
2563
+ }
2317
2564
  }
2318
- return { path, staged, patch: patch || null, binary: false };
2565
+ return null;
2566
+ }
2567
+ async function resolveComparisonBase(cwd) {
2568
+ if (!await tryGit(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], cwd)) {
2569
+ return { sha: EMPTY_TREE_SHA, ref: null };
2570
+ }
2571
+ const defaultRef = await findDefaultBranchRef(cwd);
2572
+ if (!defaultRef) return { sha: "HEAD", ref: null };
2573
+ const mergeBase = await tryGit(["merge-base", "HEAD", defaultRef], cwd);
2574
+ return mergeBase ? { sha: mergeBase, ref: defaultRef } : { sha: "HEAD", ref: null };
2319
2575
  }
2320
2576
 
2321
2577
  // src/sessions/git/status.ts
@@ -2325,7 +2581,11 @@ async function getCurrentBranch(cwd) {
2325
2581
  return out === "" || out === "HEAD" ? null : out;
2326
2582
  } catch (err) {
2327
2583
  if (err instanceof GitNotARepoError) return null;
2328
- throw err;
2584
+ try {
2585
+ return (await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd)).trim() || null;
2586
+ } catch {
2587
+ throw err;
2588
+ }
2329
2589
  }
2330
2590
  }
2331
2591
  async function getRepositoryFullName(cwd) {
@@ -2375,88 +2635,73 @@ function parseNumstat(output) {
2375
2635
  }
2376
2636
  return map;
2377
2637
  }
2378
- var STATUS_CODE_MAP = {
2638
+ var STATUS_BY_LETTER = {
2379
2639
  A: "added",
2380
2640
  M: "modified",
2381
2641
  D: "deleted",
2382
2642
  R: "renamed",
2383
2643
  C: "copied",
2644
+ T: "modified",
2645
+ // type change, such as a file becoming a symlink
2384
2646
  U: "modified"
2385
- // unmerged — treated as modified, no conflict-resolution UI in scope
2647
+ // unmerged: no conflict-resolution UI yet, so it reads as modified
2386
2648
  };
2387
- async function getWorkspaceStatus(cwd) {
2388
- const branch = await getCurrentBranch(cwd);
2389
- if (branch === null && !await isGitRepo(cwd)) {
2390
- return { isGitRepo: false, branch: null, files: [] };
2391
- }
2392
- const [porcelain, unstagedRaw, stagedRaw] = await Promise.all([
2393
- runGit(["status", "--porcelain=v1", "-z"], cwd),
2394
- runGit(["diff", "--numstat", "-z"], cwd),
2395
- runGit(["diff", "--cached", "--numstat", "-z"], cwd)
2396
- ]);
2397
- const unstagedNumstat = parseNumstat(unstagedRaw);
2398
- const stagedNumstat = parseNumstat(stagedRaw);
2399
- const files = parsePorcelain(porcelain, unstagedNumstat, stagedNumstat);
2400
- return { isGitRepo: true, branch, files };
2401
- }
2402
- function parsePorcelain(porcelain, unstagedNumstat, stagedNumstat) {
2403
- const entries = [];
2404
- const fields = porcelain.split("\0").filter((f) => f.length > 0);
2649
+ function isStatusLetter(letter) {
2650
+ return letter in STATUS_BY_LETTER;
2651
+ }
2652
+ function parseNameStatus(output) {
2653
+ const fields = output.split("\0").filter((f) => f.length > 0);
2654
+ const changed = [];
2405
2655
  let i = 0;
2406
2656
  while (i < fields.length) {
2407
- const raw = fields[i];
2408
- const x = raw[0] ?? " ";
2409
- const y = raw[1] ?? " ";
2410
- const path = raw.slice(3);
2411
- i++;
2412
- if (x === "R" || x === "C" || y === "R" || y === "C") {
2413
- const previousPath = fields[i];
2414
- i++;
2415
- if (previousPath === void 0) continue;
2416
- pushEntriesForPath(entries, path, x, y, previousPath, unstagedNumstat, stagedNumstat);
2417
- continue;
2657
+ const letter = fields[i++][0] ?? "";
2658
+ const status = isStatusLetter(letter) ? STATUS_BY_LETTER[letter] : "modified";
2659
+ if (status === "renamed" || status === "copied") {
2660
+ const previousPath = fields[i++];
2661
+ const path = fields[i++];
2662
+ if (previousPath === void 0 || path === void 0) break;
2663
+ changed.push({ path, previousPath, status });
2664
+ } else {
2665
+ const path = fields[i++];
2666
+ if (path === void 0) break;
2667
+ changed.push({ path, status });
2418
2668
  }
2419
- pushEntriesForPath(entries, path, x, y, void 0, unstagedNumstat, stagedNumstat);
2420
2669
  }
2421
- return entries;
2670
+ return changed;
2422
2671
  }
2423
- function pushEntriesForPath(entries, path, x, y, previousPath, unstagedNumstat, stagedNumstat) {
2424
- if (x === "?" && y === "?") {
2425
- const numstat = unstagedNumstat.get(path);
2426
- entries.push({
2427
- path,
2428
- status: "untracked",
2429
- staged: false,
2430
- additions: numstat?.additions ?? null,
2431
- deletions: numstat?.deletions ?? null,
2432
- binary: numstat?.binary ?? false
2433
- });
2434
- return;
2435
- }
2436
- if (x !== " " && x !== "?") {
2437
- const numstat = stagedNumstat.get(path);
2438
- entries.push({
2439
- path,
2440
- previousPath,
2441
- status: STATUS_CODE_MAP[x] ?? "modified",
2442
- staged: true,
2443
- additions: numstat?.additions ?? null,
2444
- deletions: numstat?.deletions ?? null,
2445
- binary: numstat?.binary ?? false
2446
- });
2672
+ async function getWorkspaceStatus(cwd) {
2673
+ const branch = await getCurrentBranch(cwd);
2674
+ if (branch === null && !await isGitRepo(cwd)) {
2675
+ return { isGitRepo: false, branch: null, files: [] };
2447
2676
  }
2448
- if (y !== " " && y !== "?") {
2449
- const numstat = unstagedNumstat.get(path);
2450
- entries.push({
2451
- path,
2452
- previousPath,
2453
- status: STATUS_CODE_MAP[y] ?? "modified",
2677
+ const base = await resolveComparisonBase(cwd);
2678
+ const [nameStatusRaw, numstatRaw, untrackedRaw] = await Promise.all([
2679
+ runGitDiff(["--name-status", "-z", "-M", base.sha], cwd),
2680
+ runGitDiff(["--numstat", "-z", "-M", base.sha], cwd),
2681
+ runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
2682
+ ]);
2683
+ const numstat = parseNumstat(numstatRaw);
2684
+ const tracked = parseNameStatus(nameStatusRaw).map((changed) => {
2685
+ const counts = numstat.get(changed.path);
2686
+ return {
2687
+ path: changed.path,
2688
+ previousPath: changed.previousPath,
2689
+ status: changed.status,
2454
2690
  staged: false,
2455
- additions: numstat?.additions ?? null,
2456
- deletions: numstat?.deletions ?? null,
2457
- binary: numstat?.binary ?? false
2458
- });
2459
- }
2691
+ additions: counts?.additions ?? null,
2692
+ deletions: counts?.deletions ?? null,
2693
+ binary: counts?.binary ?? false
2694
+ };
2695
+ });
2696
+ const untracked = untrackedRaw.split("\0").filter((path) => path.length > 0).map((path) => ({
2697
+ path,
2698
+ status: "untracked",
2699
+ staged: false,
2700
+ additions: null,
2701
+ deletions: null,
2702
+ binary: false
2703
+ }));
2704
+ return { isGitRepo: true, branch, baseRef: base.ref, files: [...tracked, ...untracked] };
2460
2705
  }
2461
2706
  async function enrichSessionWithGitMetadata(session) {
2462
2707
  const [branch, repositoryFullName, status, lastActivityAt] = await Promise.all([
@@ -2475,6 +2720,33 @@ async function enrichSessionWithGitMetadata(session) {
2475
2720
  };
2476
2721
  }
2477
2722
 
2723
+ // src/sessions/git/diff.ts
2724
+ async function withRenameSource(cwd, baseSha, path) {
2725
+ const nameStatus = await runGitDiff(["--name-status", "-z", "-M", baseSha], cwd);
2726
+ const previousPath = parseNameStatus(nameStatus).find((c) => c.path === path)?.previousPath;
2727
+ return previousPath === void 0 ? [path] : [previousPath, path];
2728
+ }
2729
+ async function isUntracked(cwd, path) {
2730
+ const out = await runGit(["ls-files", "--others", "--exclude-standard", "--", path], cwd);
2731
+ return out.trim() !== "";
2732
+ }
2733
+ async function getWorkspaceDiff(cwd, path, staged) {
2734
+ const base = await resolveComparisonBase(cwd);
2735
+ const pathspec = await withRenameSource(cwd, base.sha, path);
2736
+ const numstat = await runGitDiff(["--numstat", "-M", base.sha, "--", ...pathspec], cwd);
2737
+ const binary = /^(-\t-\t)/.test(numstat.trim());
2738
+ if (binary) {
2739
+ return { path, staged, patch: null, binary: true };
2740
+ }
2741
+ let patch = await runGitDiff(["-M", base.sha, "--", ...pathspec], cwd);
2742
+ if (patch.trim() === "" && await isUntracked(cwd, path)) {
2743
+ patch = await runGitDiffAllowingExitCodes(["--no-index", "--", "/dev/null", path], cwd, [
2744
+ 1
2745
+ ]).catch(() => "");
2746
+ }
2747
+ return { path, staged, patch: patch || null, binary: false };
2748
+ }
2749
+
2478
2750
  // src/ws/server.ts
2479
2751
  import { hostname } from "os";
2480
2752
  import { WebSocketServer } from "ws";
@@ -2500,23 +2772,40 @@ function isAllowedOrigin(origin, allowlist) {
2500
2772
 
2501
2773
  // src/sessions/pty-attachment.ts
2502
2774
  import * as pty from "node-pty";
2775
+ var DEFAULT_PTY_SIZE = { cols: 80, rows: 24 };
2776
+ var MAX_SNAPSHOT_JSON_BYTES = 128 * 1024;
2777
+ function keepBottomRows(rows) {
2778
+ let budget = MAX_SNAPSHOT_JSON_BYTES;
2779
+ let first = rows.length;
2780
+ while (first > 0) {
2781
+ budget -= JSON.stringify(rows[first - 1]).length;
2782
+ if (budget < 0) break;
2783
+ first -= 1;
2784
+ }
2785
+ return rows.slice(first);
2786
+ }
2787
+ function snapshotToTerminalOutput(snapshot) {
2788
+ const rows = snapshot.replace(/\r?\n$/, "").split(/\r?\n/);
2789
+ return keepBottomRows(rows).join("\r\n");
2790
+ }
2503
2791
  var PtyAttachmentManager = class {
2504
2792
  handles = /* @__PURE__ */ new Map();
2505
2793
  /**
2506
2794
  * Attempts to attach (spawning a pty if one isn't already running for this
2507
2795
  * session). Returns false, without throwing, if the pty could not be
2508
2796
  * spawned — a spawn failure here must never take down the whole daemon
2509
- * process, since every other session's pty would die with it.
2797
+ * process, since every other session's pty would die with it. `size` only
2798
+ * applies to a pty spawned here; an existing one keeps its current size.
2510
2799
  */
2511
- async ensureAttached(sessionId, tmuxSessionName, onData) {
2800
+ async ensureAttached(sessionId, tmuxSessionName, onData, size = DEFAULT_PTY_SIZE) {
2512
2801
  let handle = this.handles.get(sessionId);
2513
2802
  if (!handle) {
2514
2803
  let ptyProcess;
2515
2804
  try {
2516
2805
  ptyProcess = pty.spawn("tmux", ["attach-session", "-t", tmuxSessionName], {
2517
2806
  name: "xterm-256color",
2518
- cols: 80,
2519
- rows: 24,
2807
+ cols: size.cols,
2808
+ rows: size.rows,
2520
2809
  cwd: process.env.HOME,
2521
2810
  env: filterSpawnEnv(process.env)
2522
2811
  });
@@ -2528,20 +2817,17 @@ ${new TmuxNotFoundError().message}
2528
2817
  }
2529
2818
  return false;
2530
2819
  }
2531
- handle = { process: ptyProcess, refCount: 0, dataListeners: /* @__PURE__ */ new Set() };
2532
- this.handles.set(sessionId, handle);
2820
+ const created = { process: ptyProcess, dataListeners: /* @__PURE__ */ new Set([onData]) };
2821
+ this.handles.set(sessionId, created);
2533
2822
  ptyProcess.onData((data) => {
2534
- for (const listener of handle?.dataListeners ?? []) listener(data);
2823
+ for (const listener of created.dataListeners) listener(data);
2535
2824
  });
2536
2825
  ptyProcess.onExit(() => {
2537
- this.handles.delete(sessionId);
2826
+ if (this.handles.get(sessionId) === created) this.handles.delete(sessionId);
2538
2827
  });
2539
- handle.refCount += 1;
2540
- handle.dataListeners.add(onData);
2541
2828
  return true;
2542
2829
  }
2543
2830
  await this.sendPaneSnapshot(tmuxSessionName, onData);
2544
- handle.refCount += 1;
2545
2831
  handle.dataListeners.add(onData);
2546
2832
  return true;
2547
2833
  }
@@ -2554,19 +2840,21 @@ ${new TmuxNotFoundError().message}
2554
2840
  handle.dataListeners.add(nextOnData);
2555
2841
  return true;
2556
2842
  }
2843
+ /** The pane's current screen as terminal output; rejects if tmux cannot capture it. */
2844
+ async paneSnapshot(tmuxSessionName) {
2845
+ return snapshotToTerminalOutput(await capturePane(tmuxSessionName));
2846
+ }
2557
2847
  // Best-effort: a capture failure just leaves this listener blank until the next real write.
2558
2848
  async sendPaneSnapshot(tmuxSessionName, onData) {
2559
2849
  try {
2560
- onData(await capturePane(tmuxSessionName));
2850
+ onData(await this.paneSnapshot(tmuxSessionName));
2561
2851
  } catch {
2562
2852
  }
2563
2853
  }
2564
2854
  release(sessionId, onData) {
2565
2855
  const handle = this.handles.get(sessionId);
2566
- if (!handle) return;
2567
- handle.dataListeners.delete(onData);
2568
- handle.refCount -= 1;
2569
- if (handle.refCount <= 0) {
2856
+ if (!handle?.dataListeners.delete(onData)) return;
2857
+ if (handle.dataListeners.size === 0) {
2570
2858
  handle.process.kill();
2571
2859
  this.handles.delete(sessionId);
2572
2860
  }
@@ -2837,7 +3125,12 @@ var SessionManager = class {
2837
3125
  if (error2 instanceof TmuxNotFoundError) console.error(`
2838
3126
  ${error2.message}
2839
3127
  `);
2840
- if (!(error2 instanceof TmuxNoServerRunningError)) return;
3128
+ if (!(error2 instanceof TmuxNoServerRunningError)) {
3129
+ if (!(error2 instanceof TmuxNotFoundError)) {
3130
+ console.error("tmux list-sessions failed; skipping reconcile", error2);
3131
+ }
3132
+ return;
3133
+ }
2841
3134
  }
2842
3135
  await Promise.all(
2843
3136
  Array.from(this.sessions.values()).map(async (session) => {
@@ -2911,6 +3204,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2911
3204
  const rejectionReasonByRequest = /* @__PURE__ */ new WeakMap();
2912
3205
  const wss = new WebSocketServer({
2913
3206
  server: httpServer,
3207
+ // ws defaults to 100 MiB per frame, parsed before any signature is checked.
3208
+ maxPayload: MAX_WIRE_MESSAGE_BYTES,
2914
3209
  // Client always sends the dev-tunnel token as the sole entry (see
2915
3210
  // isValidDevTunnelToken); echoing it back is required for a spec-
2916
3211
  // compliant handshake once a Sec-WebSocket-Protocol is offered.
@@ -2934,6 +3229,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2934
3229
  broadcast({ type: "session.updated", session });
2935
3230
  });
2936
3231
  wss.on("connection", (socket, request) => {
3232
+ socket.on("error", (error2) => {
3233
+ console.warn("bondsmithd: browser socket error", error2.message);
3234
+ });
2937
3235
  const rejectionReason = rejectionReasonByRequest.get(request);
2938
3236
  if (rejectionReason) {
2939
3237
  rejectionReasonByRequest.delete(request);
@@ -2948,7 +3246,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2948
3246
  if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
2949
3247
  },
2950
3248
  (controllerId) => registerControllerConnection(controllerId, socket),
2951
- ptyAttachments
3249
+ ptyAttachments,
3250
+ () => socket.bufferedAmount
2952
3251
  );
2953
3252
  cleanupBySocket.set(socket, releaseAllSubscriptions);
2954
3253
  connectionAuthBySocket.set(socket, connectionAuth);
@@ -3012,6 +3311,10 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
3012
3311
  }
3013
3312
 
3014
3313
  // src/index.ts
3314
+ if (isVersionRequest(process.argv.slice(2))) {
3315
+ console.log(versionLine());
3316
+ process.exit(0);
3317
+ }
3015
3318
  function loadRepoRootEnv() {
3016
3319
  const repoRoot = join6(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
3017
3320
  if (!existsSync(join6(repoRoot, "pnpm-workspace.yaml"))) return;
@@ -3021,6 +3324,7 @@ function loadRepoRootEnv() {
3021
3324
  }
3022
3325
  loadRepoRootEnv();
3023
3326
  async function start() {
3327
+ console.log(versionLine());
3024
3328
  let config = getDaemonConfig();
3025
3329
  if (!config.devTunnelToken) {
3026
3330
  const generatedToken = await loadOrCreateDevTunnelToken();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bondsmith/bondsmithd",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "description": "Local daemon for Bondsmith agent sessions — runs on the user's own machine, pairs with a Bondsmith account, and exposes tmux-backed terminal sessions to the Bondsmith web app over a signed WebSocket protocol.",
6
6
  "homepage": "https://bondsmith.dev",