@bondsmith/bondsmithd 0.1.2 → 0.1.4

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 (3) hide show
  1. package/README.md +14 -0
  2. package/dist/index.js +774 -237
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { createServer } from "http";
5
5
  import { existsSync } from "fs";
6
6
  import { hostname as hostname2 } from "os";
7
- import { dirname as dirname4, join as join5 } from "path";
7
+ import { dirname as dirname4, join as join6 } from "path";
8
8
  import { fileURLToPath } from "url";
9
9
 
10
10
  // ../../packages/local-agent-protocol/src/session.ts
@@ -12,6 +12,19 @@ var SESSION_STATUSES = ["running", "exited"];
12
12
 
13
13
  // ../../packages/local-agent-protocol/src/messages.ts
14
14
  var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
15
+ var INVALID_MESSAGE_ERROR = "invalid message";
16
+
17
+ // ../../packages/local-agent-protocol/src/protocol-version.ts
18
+ var PROTOCOL_VERSION = 4;
19
+
20
+ // ../../packages/local-agent-protocol/src/project-path.ts
21
+ var PROJECT_PATH_INVALID_REASONS = [
22
+ "relative_path",
23
+ "not_found",
24
+ "not_a_directory",
25
+ "not_a_git_repo",
26
+ "permission_denied"
27
+ ];
15
28
 
16
29
  // ../../packages/local-agent-protocol/src/schemas.ts
17
30
  import { z as z2 } from "zod";
@@ -44,6 +57,13 @@ var signedConnectionChallengeSchema = z.object({
44
57
 
45
58
  // ../../packages/local-agent-protocol/src/schemas.ts
46
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
+ });
47
67
  var localSessionSchema = z2.object({
48
68
  id: z2.string(),
49
69
  title: z2.string(),
@@ -55,7 +75,8 @@ var localSessionSchema = z2.object({
55
75
  branch: z2.string().nullable(),
56
76
  repositoryFullName: z2.string().nullable(),
57
77
  lastActivityAt: z2.string().nullable(),
58
- changedFileCount: z2.number().int().nonnegative().nullable()
78
+ changedFileCount: z2.number().int().nonnegative().nullable(),
79
+ worktreeOf: z2.string().nullable()
59
80
  });
60
81
  var workspaceFileEntrySchema = z2.object({
61
82
  path: z2.string(),
@@ -69,6 +90,8 @@ var workspaceFileEntrySchema = z2.object({
69
90
  var workspaceStatusSchema = z2.object({
70
91
  isGitRepo: z2.boolean(),
71
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),
72
95
  files: z2.array(workspaceFileEntrySchema)
73
96
  });
74
97
  var workspaceFileDiffSchema = z2.object({
@@ -77,6 +100,10 @@ var workspaceFileDiffSchema = z2.object({
77
100
  patch: z2.string().nullable(),
78
101
  binary: z2.boolean()
79
102
  });
103
+ var projectPathValidationSchema = z2.discriminatedUnion("valid", [
104
+ z2.object({ valid: z2.literal(true), path: z2.string() }),
105
+ z2.object({ valid: z2.literal(false), reason: z2.enum(PROJECT_PATH_INVALID_REASONS) })
106
+ ]);
80
107
  var browserMessageSchema = z2.discriminatedUnion("type", [
81
108
  z2.object({ type: z2.literal("session.list") }),
82
109
  z2.object({
@@ -84,13 +111,41 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
84
111
  title: z2.string().min(1).max(200),
85
112
  cwd: z2.string().min(1),
86
113
  command: z2.string().min(1).max(2e3),
114
+ // Opt-in worktree creation — see messages.ts's own field comment.
115
+ // Most branch-name legality is left to git itself in createWorktree()
116
+ // (whose named error classes surface a rejection back to the
117
+ // browser), EXCEPT a leading "-": git's own argv parsing for
118
+ // `worktree add -b <branch>` treats a leading "-" as a flag, not a
119
+ // branch name, producing a raw multi-line `git branch` usage dump as
120
+ // the error instead of a clean, classified one (confirmed live
121
+ // during adversarial review) — rejected here instead, before it ever
122
+ // reaches git.
123
+ worktreeBranch: z2.string().min(1).max(250).refine((value) => !value.startsWith("-"), {
124
+ message: "branch name can't start with -"
125
+ }).optional(),
87
126
  requestId: z2.string().min(1).max(200).optional()
88
127
  }),
89
- z2.object({ type: z2.literal("session.attach"), sessionId: z2.string() }),
128
+ z2.object({
129
+ type: z2.literal("session.attach"),
130
+ sessionId: z2.string(),
131
+ // Optional (not required) so an older daemon build, or a stale queued
132
+ // envelope signed before this field existed, still parses — see
133
+ // session.attached/error's own requestId fields for why this matters:
134
+ // without a requestId round-tripped on both the ack and the error
135
+ // path, the browser can only guess which pending attachSession() call
136
+ // an incoming error/ack belongs to (oldest-pending heuristic), which
137
+ // misattributes a daemon error for ANY other in-flight command
138
+ // (observed: a session.resize racing ahead of its own session.attach)
139
+ // to the wrong promise — a confirmed bug, not a hypothetical.
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)
143
+ }),
144
+ z2.object({ type: z2.literal("session.detach"), sessionId: z2.string() }),
90
145
  z2.object({
91
146
  type: z2.literal("session.input"),
92
147
  sessionId: z2.string(),
93
- data: z2.string()
148
+ data: z2.string().max(MAX_INPUT_CHARS)
94
149
  }),
95
150
  z2.object({
96
151
  type: z2.literal("session.insertText"),
@@ -100,8 +155,7 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
100
155
  z2.object({
101
156
  type: z2.literal("session.resize"),
102
157
  sessionId: z2.string(),
103
- cols: z2.number().int().positive(),
104
- rows: z2.number().int().positive()
158
+ ...terminalSizeSchema.shape
105
159
  }),
106
160
  z2.object({ type: z2.literal("session.stop"), sessionId: z2.string() }),
107
161
  z2.object({
@@ -116,6 +170,11 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
116
170
  staged: z2.boolean(),
117
171
  requestId: z2.string().min(1).max(200)
118
172
  }),
173
+ z2.object({
174
+ type: z2.literal("project.validatePath"),
175
+ path: z2.string().min(1).max(4096),
176
+ requestId: z2.string().min(1).max(200)
177
+ }),
119
178
  z2.object({ type: z2.literal("daemon.getInfo") }),
120
179
  // Second-controller enrollment (ADR-011/ADR-012, BD-433) — deliberately
121
180
  // unsigned: the requesting browser has no trusted key yet to sign with.
@@ -166,8 +225,22 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
166
225
  // session" (observed during a reconnect: attachSession() was
167
226
  // fire-and-forget, so terminal.onData could fire before attach
168
227
  // completed).
169
- z2.object({ type: z2.literal("session.attached"), sessionId: z2.string() }),
170
- z2.object({ type: z2.literal("error"), message: z2.string() }),
228
+ z2.object({
229
+ type: z2.literal("session.attached"),
230
+ sessionId: z2.string(),
231
+ requestId: z2.string().optional()
232
+ }),
233
+ z2.object({
234
+ type: z2.literal("error"),
235
+ message: z2.string(),
236
+ // Echoes the triggering message's requestId when it had one, so the
237
+ // browser can match this error to the exact pending request instead
238
+ // of guessing "oldest pending create, then oldest pending attach" —
239
+ // undefined for error paths with no requestId to echo (most session.*
240
+ // commands still don't carry one; see session.attach above for the
241
+ // one this was added to fix).
242
+ requestId: z2.string().optional()
243
+ }),
171
244
  z2.object({
172
245
  type: z2.literal("workspace.status"),
173
246
  sessionId: z2.string(),
@@ -180,6 +253,11 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
180
253
  requestId: z2.string(),
181
254
  diff: workspaceFileDiffSchema
182
255
  }),
256
+ z2.object({
257
+ type: z2.literal("project.pathValidated"),
258
+ requestId: z2.string(),
259
+ result: projectPathValidationSchema
260
+ }),
183
261
  z2.object({
184
262
  type: z2.literal("daemon.info"),
185
263
  hostname: z2.string(),
@@ -199,7 +277,10 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
199
277
  challenge: z2.string().min(1),
200
278
  daemonFingerprint: z2.string().min(1),
201
279
  hostname: z2.string(),
202
- expiresAt: z2.number().int().positive()
280
+ expiresAt: z2.number().int().positive(),
281
+ // .catch(undefined): a malformed informational field must never fail the whole handshake message.
282
+ protocolVersion: z2.number().int().positive().optional().catch(void 0),
283
+ daemonVersion: z2.string().optional().catch(void 0)
203
284
  }),
204
285
  z2.object({
205
286
  type: z2.literal("connection.authenticated"),
@@ -278,13 +359,15 @@ import { z as z3 } from "zod";
278
359
  var SIGNABLE_ACTIONS = [
279
360
  "session.create",
280
361
  "session.attach",
362
+ "session.detach",
281
363
  "session.input",
282
364
  "session.insertText",
283
365
  "session.resize",
284
366
  "session.stop",
285
367
  "session.list",
286
368
  "workspace.getStatus",
287
- "workspace.getDiff"
369
+ "workspace.getDiff",
370
+ "project.validatePath"
288
371
  ];
289
372
  var envelopeSchema = z3.object({
290
373
  version: z3.literal(1),
@@ -613,7 +696,7 @@ async function callTRPCQuery(baseUrl, procedure, input) {
613
696
  // src/pairing/pairing.ts
614
697
  var DEFAULT_POLL_INTERVAL_MS = 3e3;
615
698
  function sleep(ms) {
616
- return new Promise((resolve) => setTimeout(resolve, ms));
699
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
617
700
  }
618
701
  async function ensurePaired(options) {
619
702
  const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
@@ -750,14 +833,137 @@ async function bindFirstController(params, response) {
750
833
  }
751
834
  }
752
835
 
836
+ // src/projects/validate-path.ts
837
+ import { stat } from "fs/promises";
838
+ import { isAbsolute, resolve } from "path";
839
+
840
+ // src/sessions/expand-home.ts
841
+ import { homedir } from "os";
842
+ import { join } from "path";
843
+ function expandHome(cwd) {
844
+ if (cwd === "~") return homedir();
845
+ if (cwd.startsWith("~/")) return join(homedir(), cwd.slice(2));
846
+ return cwd;
847
+ }
848
+
849
+ // src/sessions/git/client.ts
850
+ import { execFile as execFileCb } from "child_process";
851
+ import { promisify } from "util";
852
+ var execFile = promisify(execFileCb);
853
+ var MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
854
+ var SAFE_DIFF_FLAGS = ["--no-ext-diff", "--no-textconv"];
855
+ var GitNotARepoError = class extends Error {
856
+ constructor() {
857
+ super("not a git repository");
858
+ this.name = "GitNotARepoError";
859
+ }
860
+ };
861
+ function isNotARepoError(error2) {
862
+ if (typeof error2 !== "object" || error2 === null) return false;
863
+ const stderr = error2.stderr;
864
+ return typeof stderr === "string" && stderr.includes("not a git repository");
865
+ }
866
+ async function runGit(args, cwd) {
867
+ try {
868
+ const { stdout } = await execFile("git", args, { cwd, maxBuffer: MAX_GIT_OUTPUT_BYTES });
869
+ return stdout;
870
+ } catch (error2) {
871
+ if (isNotARepoError(error2)) throw new GitNotARepoError();
872
+ throw error2;
873
+ }
874
+ }
875
+ async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
876
+ try {
877
+ const { stdout } = await execFile("git", args, { cwd, maxBuffer: MAX_GIT_OUTPUT_BYTES });
878
+ return stdout;
879
+ } catch (error2) {
880
+ if (isNotARepoError(error2)) throw new GitNotARepoError();
881
+ const code = error2.code;
882
+ const stdout = error2.stdout;
883
+ if (typeof code === "number" && allowedExitCodes.includes(code) && typeof stdout === "string") {
884
+ return stdout;
885
+ }
886
+ throw error2;
887
+ }
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
+ }
895
+
896
+ // src/projects/validate-path.ts
897
+ var NOT_FOUND_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
898
+ var PERMISSION_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM"]);
899
+ function invalidReasonForFsError(error2) {
900
+ const code = error2?.code;
901
+ if (typeof code !== "string") return null;
902
+ if (NOT_FOUND_CODES.has(code)) return "not_found";
903
+ if (PERMISSION_CODES.has(code)) return "permission_denied";
904
+ return null;
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
+ }
927
+ async function validateProjectPath(input) {
928
+ const expanded = expandHome(input.trim());
929
+ if (!isAbsolute(expanded)) return { valid: false, reason: "relative_path" };
930
+ const path = resolve(expanded);
931
+ try {
932
+ const stats = await stat(path);
933
+ if (!stats.isDirectory()) return { valid: false, reason: "not_a_directory" };
934
+ } catch (error2) {
935
+ const reason = invalidReasonForFsError(error2);
936
+ if (reason) return { valid: false, reason };
937
+ throw error2;
938
+ }
939
+ try {
940
+ const insideWorkTree = (await runGit(["rev-parse", "--is-inside-work-tree"], path)).trim();
941
+ if (insideWorkTree !== "true") return { valid: false, reason: "not_a_git_repo" };
942
+ } catch (error2) {
943
+ if (error2 instanceof GitNotARepoError) return { valid: false, reason: "not_a_git_repo" };
944
+ throw gitFailure(error2, path);
945
+ }
946
+ return { valid: true, path };
947
+ }
948
+
753
949
  // src/relay/relay-client.ts
754
950
  import { sign } from "crypto";
755
951
  import WebSocket from "ws";
756
952
 
953
+ // src/version.ts
954
+ var DAEMON_VERSION = true ? "0.1.4" : "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
+ }
962
+
757
963
  // src/sessions/tmux/client.ts
758
- import { execFile as execFileCb } from "child_process";
759
- import { promisify } from "util";
760
- var execFile = promisify(execFileCb);
964
+ import { execFile as execFileCb2 } from "child_process";
965
+ import { promisify as promisify2 } from "util";
966
+ var execFile2 = promisify2(execFileCb2);
761
967
  var TmuxNoServerRunningError = class extends Error {
762
968
  constructor() {
763
969
  super("no tmux server running");
@@ -793,7 +999,7 @@ function filterSpawnEnv(env) {
793
999
  }
794
1000
  async function runTmux(args, options) {
795
1001
  try {
796
- const { stdout } = await execFile("tmux", args, { ...options, encoding: "utf8" });
1002
+ const { stdout } = await execFile2("tmux", args, { ...options, encoding: "utf8" });
797
1003
  return stdout;
798
1004
  } catch (error2) {
799
1005
  if (error2?.code === "ENOENT") throw new TmuxNotFoundError();
@@ -824,9 +1030,18 @@ async function listTmuxSessions(formatString) {
824
1030
  throw error2;
825
1031
  }
826
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
+ ];
827
1040
  function isNoServerError(error2) {
828
- if (typeof error2 !== "object" || error2 === null) return true;
829
- 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));
830
1045
  }
831
1046
 
832
1047
  // src/security/verify-command.ts
@@ -834,8 +1049,8 @@ import { verify as verify2 } from "crypto";
834
1049
 
835
1050
  // src/security/trusted-controllers.ts
836
1051
  import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
837
- import { homedir } from "os";
838
- import { dirname, join } from "path";
1052
+ import { homedir as homedir2 } from "os";
1053
+ import { dirname, join as join2 } from "path";
839
1054
  import { z as z9 } from "zod";
840
1055
  var trustedControllerSchema = z9.object({
841
1056
  id: z9.string(),
@@ -858,7 +1073,7 @@ var trustedControllersFileSchema = z9.object({
858
1073
  controllers: z9.array(trustedControllerSchema)
859
1074
  });
860
1075
  function defaultTrustedControllersPath() {
861
- return join(homedir(), ".bondsmith", "trusted-controllers.json");
1076
+ return join2(homedir2(), ".bondsmith", "trusted-controllers.json");
862
1077
  }
863
1078
  async function loadTrustedControllers(path = defaultTrustedControllersPath()) {
864
1079
  let raw;
@@ -1007,6 +1222,76 @@ async function verifyCommand(command, daemonFingerprint, connectionState, truste
1007
1222
  return { ok: true };
1008
1223
  }
1009
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
+
1010
1295
  // src/ws/router.ts
1011
1296
  var SIGNABLE_ACTION_SET = new Set(SIGNABLE_ACTIONS);
1012
1297
  function isSignableAction(type) {
@@ -1033,18 +1318,40 @@ function verifyRejectionMessage(reason) {
1033
1318
  var REVOKED_REJECTION_MESSAGE = "connection has not completed its handshake";
1034
1319
  var BRACKETED_PASTE_START = "\x1B[200~";
1035
1320
  var BRACKETED_PASTE_END = "\x1B[201~";
1036
- 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) {
1037
1328
  const outputListeners = /* @__PURE__ */ new Map();
1038
1329
  const attachLocks = /* @__PURE__ */ new Map();
1330
+ let closed = false;
1331
+ let admissionChain = Promise.resolve();
1039
1332
  const conn = {
1040
1333
  subscriptions: /* @__PURE__ */ new Set(),
1041
1334
  send,
1042
- registerOutputListener: (sessionId, listener) => {
1043
- 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);
1044
1345
  },
1045
- getOutputListener: (sessionId) => outputListeners.get(sessionId),
1046
1346
  connectionAuth,
1047
1347
  markControllerId,
1348
+ admitInOrder: (fn) => {
1349
+ const next = admissionChain.catch(() => {
1350
+ }).then(fn);
1351
+ admissionChain = next.catch(() => {
1352
+ });
1353
+ return next;
1354
+ },
1048
1355
  withAttachLock: (sessionId, fn) => {
1049
1356
  const previous = attachLocks.get(sessionId) ?? Promise.resolve();
1050
1357
  const next = previous.catch(() => {
@@ -1058,15 +1365,15 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
1058
1365
  }
1059
1366
  };
1060
1367
  function releaseAllSubscriptions() {
1061
- for (const sessionId of conn.subscriptions) {
1062
- const listener = outputListeners.get(sessionId);
1063
- if (listener) ptyAttachments.release(sessionId, listener);
1368
+ closed = true;
1369
+ for (const sessionId of [...conn.subscriptions]) {
1370
+ releaseSession(conn, ptyAttachments, sessionId);
1064
1371
  }
1065
1372
  }
1066
1373
  return { conn, releaseAllSubscriptions };
1067
1374
  }
1068
- function error(message) {
1069
- return { type: "error", message };
1375
+ function error(message, requestId) {
1376
+ return { type: "error", message, requestId };
1070
1377
  }
1071
1378
  function errorMessageOf(err, fallback) {
1072
1379
  return err instanceof Error ? err.message : fallback;
@@ -1082,35 +1389,39 @@ async function handleBrowserMessage(conn, raw, deps) {
1082
1389
  const { envelope } = signedCommand.data;
1083
1390
  const innerMessage = browserMessageSchema.safeParse(envelope.payload);
1084
1391
  if (!innerMessage.success || innerMessage.data.type !== envelope.action) {
1085
- conn.send(error("invalid message"));
1086
- return;
1087
- }
1088
- const verdict = await verifyCommand(
1089
- signedCommand.data,
1090
- deps.daemonFingerprint,
1091
- conn.connectionAuth,
1092
- deps.trustedControllersPath
1093
- );
1094
- if (!verdict.ok) {
1095
- conn.send(error(verifyRejectionMessage(verdict.reason)));
1096
- return;
1097
- }
1098
- if (conn.connectionAuth.isRevoked()) {
1099
- conn.send(error(REVOKED_REJECTION_MESSAGE));
1392
+ conn.send(error(INVALID_MESSAGE_ERROR));
1100
1393
  return;
1101
1394
  }
1395
+ const admitted = await conn.admitInOrder(async () => {
1396
+ const verdict = await verifyCommand(
1397
+ signedCommand.data,
1398
+ deps.daemonFingerprint,
1399
+ conn.connectionAuth,
1400
+ deps.trustedControllersPath
1401
+ );
1402
+ if (!verdict.ok) {
1403
+ conn.send(error(verifyRejectionMessage(verdict.reason)));
1404
+ return false;
1405
+ }
1406
+ if (conn.connectionAuth.isRevoked()) {
1407
+ conn.send(error(REVOKED_REJECTION_MESSAGE));
1408
+ return false;
1409
+ }
1410
+ return true;
1411
+ });
1412
+ if (!admitted) return;
1102
1413
  await dispatch(conn, innerMessage.data, deps);
1103
1414
  return;
1104
1415
  }
1105
1416
  const parsed = browserMessageSchema.safeParse(raw);
1106
1417
  if (!parsed.success) {
1107
- conn.send(error("invalid message"));
1418
+ conn.send(error(INVALID_MESSAGE_ERROR));
1108
1419
  return;
1109
1420
  }
1110
1421
  if (parsed.data.type === "connection.authenticate") {
1111
- const result = await conn.connectionAuth.authenticate(
1112
- parsed.data.signed,
1113
- deps.trustedControllersPath
1422
+ const { signed } = parsed.data;
1423
+ const result = await conn.admitInOrder(
1424
+ () => conn.connectionAuth.authenticate(signed, deps.trustedControllersPath)
1114
1425
  );
1115
1426
  if (!result.ok) {
1116
1427
  conn.send({ type: "connection.authenticationFailed", reason: result.reason });
@@ -1132,6 +1443,7 @@ async function dispatch(conn, message, {
1132
1443
  broadcast,
1133
1444
  getWorkspaceStatus: getWorkspaceStatus2,
1134
1445
  getWorkspaceDiff: getWorkspaceDiff2,
1446
+ validateProjectPath: validateProjectPath2,
1135
1447
  hostname: hostname3,
1136
1448
  daemonFingerprint,
1137
1449
  pendingAuthorizations,
@@ -1143,26 +1455,39 @@ async function dispatch(conn, message, {
1143
1455
  return;
1144
1456
  }
1145
1457
  case "session.create": {
1146
- const { title, cwd, command, requestId } = message;
1458
+ const { title, cwd, command, worktreeBranch, requestId } = message;
1147
1459
  try {
1148
- const session = await sessionManager.create({ title, cwd, command });
1460
+ const session = await sessionManager.create({ title, cwd, command, worktreeBranch });
1149
1461
  broadcast({ type: "session.created", session, requestId });
1150
1462
  } catch (err) {
1151
- conn.send(error(errorMessageOf(err, "failed to create session")));
1463
+ conn.send(error(errorMessageOf(err, "failed to create session"), requestId));
1152
1464
  }
1153
1465
  return;
1154
1466
  }
1155
1467
  case "session.attach": {
1468
+ const { requestId } = message;
1156
1469
  if (!isValidSessionId(message.sessionId)) {
1157
- conn.send(error("invalid session id"));
1470
+ conn.send(error("invalid session id", requestId));
1471
+ return;
1472
+ }
1473
+ const session = sessionManager.get(message.sessionId);
1474
+ if (!session) {
1475
+ conn.send(error("unknown session", requestId));
1158
1476
  return;
1159
1477
  }
1160
- const session = getSessionOrError(sessionManager, conn, message.sessionId);
1161
- if (!session) return;
1162
1478
  await conn.withAttachLock(message.sessionId, async () => {
1163
1479
  const previousListener = conn.getOutputListener(message.sessionId);
1164
- const onData = (data) => {
1165
- conn.send({ type: "terminal.output", sessionId: message.sessionId, data });
1480
+ const output = createOutputPacer({
1481
+ send: (data) => conn.send({ type: "terminal.output", sessionId: message.sessionId, data }),
1482
+ bufferedBytes: conn.bufferedBytes,
1483
+ snapshot: () => ptyAttachments.paneSnapshot(session.tmuxSessionName)
1484
+ });
1485
+ const onData = output.write;
1486
+ const abandonedBecauseClosed = () => {
1487
+ if (!conn.isClosed()) return false;
1488
+ ptyAttachments.release(message.sessionId, onData);
1489
+ output.dispose();
1490
+ return true;
1166
1491
  };
1167
1492
  if (previousListener && await ptyAttachments.swapListener(
1168
1493
  message.sessionId,
@@ -1170,26 +1495,38 @@ async function dispatch(conn, message, {
1170
1495
  previousListener,
1171
1496
  onData
1172
1497
  )) {
1498
+ if (abandonedBecauseClosed()) return;
1173
1499
  conn.subscriptions.add(message.sessionId);
1174
- conn.registerOutputListener(message.sessionId, onData);
1175
- conn.send({ type: "session.attached", sessionId: message.sessionId });
1500
+ conn.registerOutputListener(message.sessionId, onData, output.dispose);
1501
+ conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
1176
1502
  return;
1177
1503
  }
1178
1504
  if (previousListener) {
1179
1505
  ptyAttachments.release(message.sessionId, previousListener);
1506
+ conn.unregisterOutputListener(message.sessionId);
1180
1507
  }
1181
1508
  const attached = await ptyAttachments.ensureAttached(
1182
1509
  message.sessionId,
1183
1510
  session.tmuxSessionName,
1184
- onData
1511
+ onData,
1512
+ message.size
1185
1513
  );
1186
1514
  if (!attached) {
1187
- conn.send(error("failed to attach to session"));
1515
+ output.dispose();
1516
+ conn.send(error("failed to attach to session", requestId));
1188
1517
  return;
1189
1518
  }
1519
+ if (abandonedBecauseClosed()) return;
1190
1520
  conn.subscriptions.add(message.sessionId);
1191
- conn.registerOutputListener(message.sessionId, onData);
1192
- conn.send({ type: "session.attached", sessionId: message.sessionId });
1521
+ conn.registerOutputListener(message.sessionId, onData, output.dispose);
1522
+ conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
1523
+ });
1524
+ return;
1525
+ }
1526
+ case "session.detach": {
1527
+ if (!isValidSessionId(message.sessionId)) return;
1528
+ await conn.withAttachLock(message.sessionId, async () => {
1529
+ releaseSession(conn, ptyAttachments, message.sessionId);
1193
1530
  });
1194
1531
  return;
1195
1532
  }
@@ -1265,6 +1602,15 @@ async function dispatch(conn, message, {
1265
1602
  }
1266
1603
  return;
1267
1604
  }
1605
+ case "project.validatePath": {
1606
+ try {
1607
+ const result = await validateProjectPath2(message.path);
1608
+ conn.send({ type: "project.pathValidated", requestId: message.requestId, result });
1609
+ } catch (err) {
1610
+ conn.send(error(errorMessageOf(err, "failed to validate project path"), message.requestId));
1611
+ }
1612
+ return;
1613
+ }
1268
1614
  case "daemon.getInfo": {
1269
1615
  conn.send({ type: "daemon.info", hostname: hostname3(), daemonFingerprint });
1270
1616
  return;
@@ -1587,7 +1933,9 @@ function startRelayClient(deps) {
1587
1933
  },
1588
1934
  () => {
1589
1935
  },
1590
- deps.ptyAttachments
1936
+ deps.ptyAttachments,
1937
+ // One socket carries every browser session, so a backed-up relay link slows all of them together.
1938
+ () => ws.bufferedAmount
1591
1939
  );
1592
1940
  }
1593
1941
  function handleSessionOpened(ws, relaySessionId) {
@@ -1615,7 +1963,9 @@ function startRelayClient(deps) {
1615
1963
  challenge: challenge.challenge,
1616
1964
  daemonFingerprint: deps.daemonFingerprint,
1617
1965
  hostname: deps.hostname(),
1618
- expiresAt: challenge.expiresAt
1966
+ expiresAt: challenge.expiresAt,
1967
+ protocolVersion: PROTOCOL_VERSION,
1968
+ daemonVersion: DAEMON_VERSION
1619
1969
  });
1620
1970
  }
1621
1971
  function handleFrame(frame) {
@@ -1713,10 +2063,10 @@ function startRelayClient(deps) {
1713
2063
  // src/security/daemon-identity.ts
1714
2064
  import { generateKeyPairSync, sign as sign2, verify as verify4 } from "crypto";
1715
2065
  import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1716
- import { homedir as homedir2 } from "os";
1717
- import { dirname as dirname2, join as join2 } from "path";
2066
+ import { homedir as homedir3 } from "os";
2067
+ import { dirname as dirname2, join as join3 } from "path";
1718
2068
  function defaultCredentialsPath() {
1719
- return join2(homedir2(), ".bondsmith", "credentials");
2069
+ return join3(homedir3(), ".bondsmith", "credentials");
1720
2070
  }
1721
2071
  async function loadOrCreateDaemonIdentity(credentialsPath = defaultCredentialsPath()) {
1722
2072
  const existing = await tryReadIdentity(credentialsPath);
@@ -1778,10 +2128,10 @@ function extractPemBlock(text, label) {
1778
2128
  // src/security/dev-tunnel-token-file.ts
1779
2129
  import { randomBytes as randomBytes2 } from "crypto";
1780
2130
  import { mkdir as mkdir3, readFile as readFile3, rename as rename3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
1781
- import { homedir as homedir3 } from "os";
1782
- import { dirname as dirname3, join as join3 } from "path";
2131
+ import { homedir as homedir4 } from "os";
2132
+ import { dirname as dirname3, join as join4 } from "path";
1783
2133
  function defaultDevTunnelTokenPath() {
1784
- return join3(homedir3(), ".bondsmith", "dev-tunnel-token");
2134
+ return join4(homedir4(), ".bondsmith", "dev-tunnel-token");
1785
2135
  }
1786
2136
  async function loadOrCreateDevTunnelToken(tokenPath = defaultDevTunnelTokenPath()) {
1787
2137
  const existing = await tryReadToken(tokenPath);
@@ -1813,12 +2163,27 @@ async function persistToken(tokenPath, token) {
1813
2163
 
1814
2164
  // src/security/pending-authorizations.ts
1815
2165
  import { createHash, randomUUID } from "crypto";
2166
+
2167
+ // src/security/controller-label.ts
2168
+ var MAX_LABEL_CHARACTERS = 60;
2169
+ var MAX_STACKED_MARKS = 3;
2170
+ var FALLBACK_LABEL = "Unnamed browser";
2171
+ var EXCESS_MARKS = new RegExp(`(\\p{M}{${MAX_STACKED_MARKS}})\\p{M}+`, "gu");
2172
+ function sanitizeControllerLabel(raw) {
2173
+ const printable = raw.replace(/\p{C}/gu, " ").replace(EXCESS_MARKS, "$1").replace(/\s+/g, " ").trim();
2174
+ const clipped = Array.from(printable).slice(0, MAX_LABEL_CHARACTERS).join("").trim();
2175
+ return clipped === "" ? FALLBACK_LABEL : clipped;
2176
+ }
2177
+
2178
+ // src/security/pending-authorizations.ts
1816
2179
  function controllerIdFromPublicKeySync(publicKeyPem) {
1817
2180
  return createHash("sha256").update(publicKeyPem).digest("hex").slice(0, 32);
1818
2181
  }
1819
2182
  var REQUEST_EXPIRY_MS = 5 * 6e4;
1820
2183
  var MAX_REQUESTS_PER_WINDOW = 5;
1821
2184
  var RATE_LIMIT_WINDOW_MS = 6e4;
2185
+ var MAX_PENDING_REQUESTS = 5;
2186
+ var MAX_NEW_REQUESTS_PER_WINDOW = 10;
1822
2187
  var PendingAuthorizationStore = class {
1823
2188
  // Invariant: at most one PendingEntry per controllerId at any time. A
1824
2189
  // conflicting requestEnrollment call for a controllerId that already has
@@ -1829,6 +2194,7 @@ var PendingAuthorizationStore = class {
1829
2194
  // resolve a newer, unrelated one.
1830
2195
  pending = /* @__PURE__ */ new Map();
1831
2196
  requestTimestamps = /* @__PURE__ */ new Map();
2197
+ allRequestTimestamps = [];
1832
2198
  isInteractive;
1833
2199
  trustedControllersPath;
1834
2200
  constructor(options = {}) {
@@ -1874,7 +2240,7 @@ var PendingAuthorizationStore = class {
1874
2240
  result: existing.resultPromise
1875
2241
  };
1876
2242
  }
1877
- if (this.isRateLimited(controllerId)) {
2243
+ if (this.isRateLimited(controllerId) || this.isOverGlobalLimits()) {
1878
2244
  return { ok: false, reason: "rate_limited" };
1879
2245
  }
1880
2246
  this.recordRequestTimestamp(controllerId);
@@ -1882,15 +2248,15 @@ var PendingAuthorizationStore = class {
1882
2248
  const authorization = {
1883
2249
  controllerId,
1884
2250
  publicKeyPem: params.publicKeyPem,
1885
- label: params.label,
2251
+ label: sanitizeControllerLabel(params.label),
1886
2252
  fingerprint: "",
1887
2253
  requestedAt: now,
1888
2254
  expiresAt: now + REQUEST_EXPIRY_MS,
1889
2255
  requestToken: randomUUID()
1890
2256
  };
1891
2257
  let settleFn;
1892
- const resultPromise = new Promise((resolve) => {
1893
- settleFn = resolve;
2258
+ const resultPromise = new Promise((resolve2) => {
2259
+ settleFn = resolve2;
1894
2260
  });
1895
2261
  const entry = {
1896
2262
  authorization,
@@ -1970,19 +2336,33 @@ var PendingAuthorizationStore = class {
1970
2336
  entry.settle("cancelled");
1971
2337
  }
1972
2338
  }
2339
+ /** True while this exact request is still waiting for an answer, so a queued prompt can skip one that expired or was cancelled. */
2340
+ isPending(controllerId, requestToken) {
2341
+ const entry = this.pending.get(controllerId);
2342
+ return entry !== void 0 && !entry.settled && entry.authorization.requestToken === requestToken && entry.authorization.expiresAt > Date.now();
2343
+ }
1973
2344
  isRateLimited(controllerId) {
1974
2345
  const timestamps = this.requestTimestamps.get(controllerId) ?? [];
1975
2346
  const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
1976
2347
  const recent = timestamps.filter((t) => t > windowStart);
1977
2348
  return recent.length >= MAX_REQUESTS_PER_WINDOW;
1978
2349
  }
1979
- recordRequestTimestamp(controllerId) {
2350
+ isOverGlobalLimits() {
1980
2351
  const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
1981
- const timestamps = (this.requestTimestamps.get(controllerId) ?? []).filter(
1982
- (t) => t > windowStart
1983
- );
1984
- timestamps.push(Date.now());
1985
- this.requestTimestamps.set(controllerId, timestamps);
2352
+ const recent = this.allRequestTimestamps.filter((t) => t > windowStart);
2353
+ return recent.length >= MAX_NEW_REQUESTS_PER_WINDOW || this.pending.size >= MAX_PENDING_REQUESTS;
2354
+ }
2355
+ recordRequestTimestamp(controllerId) {
2356
+ const now = Date.now();
2357
+ const windowStart = now - RATE_LIMIT_WINDOW_MS;
2358
+ this.allRequestTimestamps = this.allRequestTimestamps.filter((t) => t > windowStart);
2359
+ this.allRequestTimestamps.push(now);
2360
+ for (const [key, timestamps] of this.requestTimestamps) {
2361
+ if (!timestamps.some((t) => t > windowStart)) this.requestTimestamps.delete(key);
2362
+ }
2363
+ const recent = (this.requestTimestamps.get(controllerId) ?? []).filter((t) => t > windowStart);
2364
+ recent.push(now);
2365
+ this.requestTimestamps.set(controllerId, recent);
1986
2366
  }
1987
2367
  };
1988
2368
 
@@ -2094,81 +2474,101 @@ function startRevocationSync(deps) {
2094
2474
  // src/security/terminal-approval.ts
2095
2475
  import { createInterface } from "readline";
2096
2476
  function presentPendingAuthorizationOnTerminal(store) {
2097
- return (pending) => {
2477
+ const waiting = [];
2478
+ let prompting = false;
2479
+ let inputEnded = false;
2480
+ const record = (pending, approved) => {
2481
+ store.resolve(pending.controllerId, pending.requestToken, approved).catch((error2) => {
2482
+ console.error("Failed to record controller authorization decision", error2);
2483
+ });
2484
+ };
2485
+ const askNext = () => {
2486
+ if (prompting) return;
2487
+ const pending = waiting.shift();
2488
+ if (!pending) return;
2489
+ if (!store.isPending(pending.controllerId, pending.requestToken)) {
2490
+ askNext();
2491
+ return;
2492
+ }
2493
+ if (inputEnded) {
2494
+ record(pending, false);
2495
+ askNext();
2496
+ return;
2497
+ }
2498
+ prompting = true;
2098
2499
  console.log("");
2099
2500
  console.log(" Bondsmith controller authorization request");
2100
- console.log(` Browser: ${pending.label}`);
2501
+ console.log(` Browser: "${pending.label}"`);
2101
2502
  console.log(` Fingerprint: ${pending.fingerprint}`);
2503
+ if (waiting.length > 0) console.log(` (${waiting.length} more waiting after this one)`);
2102
2504
  console.log("");
2103
2505
  console.log(" Allow this browser to control local sessions? [y/N]");
2104
2506
  const rl = createInterface({ input: process.stdin, output: process.stdout });
2105
- rl.question(" > ", (answer) => {
2507
+ let answered = false;
2508
+ let expired = false;
2509
+ const expiryTimer = setTimeout(
2510
+ () => {
2511
+ expired = true;
2512
+ rl.close();
2513
+ },
2514
+ Math.max(0, pending.expiresAt - Date.now())
2515
+ );
2516
+ const finish = (approved) => {
2517
+ if (answered) return;
2518
+ answered = true;
2519
+ clearTimeout(expiryTimer);
2106
2520
  rl.close();
2107
- const approved = answer.trim().toLowerCase() === "y";
2108
- store.resolve(pending.controllerId, pending.requestToken, approved).catch((error2) => {
2109
- console.error("Failed to record controller authorization decision", error2);
2110
- });
2111
- console.log(approved ? " Approved." : " Denied.");
2521
+ record(pending, approved);
2522
+ if (expired) console.log(" Request expired.");
2523
+ else console.log(approved ? " Approved." : " Denied.");
2112
2524
  console.log("");
2525
+ prompting = false;
2526
+ askNext();
2527
+ };
2528
+ rl.question(" > ", (answer) => finish(answer.trim().toLowerCase() === "y"));
2529
+ rl.on("SIGINT", () => finish(false));
2530
+ rl.on("close", () => {
2531
+ if (!answered && !expired) inputEnded = true;
2532
+ finish(false);
2113
2533
  });
2114
2534
  };
2535
+ return (pending) => {
2536
+ waiting.push(pending);
2537
+ askNext();
2538
+ };
2115
2539
  }
2116
2540
 
2117
- // src/sessions/git/client.ts
2118
- import { execFile as execFileCb2 } from "child_process";
2119
- import { promisify as promisify2 } from "util";
2120
- var execFile2 = promisify2(execFileCb2);
2121
- var GitNotARepoError = class extends Error {
2122
- constructor() {
2123
- super("not a git repository");
2124
- this.name = "GitNotARepoError";
2125
- }
2126
- };
2127
- function isNotARepoError(error2) {
2128
- if (typeof error2 !== "object" || error2 === null) return false;
2129
- const stderr = error2.stderr;
2130
- return typeof stderr === "string" && stderr.includes("not a git repository");
2131
- }
2132
- async function runGit(args, cwd) {
2541
+ // src/sessions/git/base.ts
2542
+ var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
2543
+ var DEFAULT_BRANCH_CANDIDATES = ["origin/main", "origin/master", "main", "master"];
2544
+ async function tryGit(args, cwd) {
2133
2545
  try {
2134
- const { stdout } = await execFile2("git", args, { cwd });
2135
- return stdout;
2136
- } catch (error2) {
2137
- if (isNotARepoError(error2)) throw new GitNotARepoError();
2138
- throw error2;
2546
+ return (await runGit(args, cwd)).trim() || null;
2547
+ } catch {
2548
+ return null;
2139
2549
  }
2140
2550
  }
2141
- async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
2142
- try {
2143
- const { stdout } = await execFile2("git", args, { cwd });
2144
- return stdout;
2145
- } catch (error2) {
2146
- if (isNotARepoError(error2)) throw new GitNotARepoError();
2147
- const code = error2.code;
2148
- const stdout = error2.stdout;
2149
- if (typeof code === "number" && allowedExitCodes.includes(code) && typeof stdout === "string") {
2150
- return stdout;
2551
+ async function findDefaultBranchRef(cwd) {
2552
+ const remoteHead = await tryGit(
2553
+ ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
2554
+ cwd
2555
+ );
2556
+ if (remoteHead) return remoteHead;
2557
+ for (const candidate of DEFAULT_BRANCH_CANDIDATES) {
2558
+ if (await tryGit(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], cwd)) {
2559
+ return candidate;
2151
2560
  }
2152
- throw error2;
2153
2561
  }
2562
+ return null;
2154
2563
  }
2155
-
2156
- // src/sessions/git/diff.ts
2157
- async function getWorkspaceDiff(cwd, path, staged) {
2158
- const numstatArgs = staged ? ["diff", "--cached", "--numstat", "--", path] : ["diff", "--numstat", "--", path];
2159
- const numstat = await runGit(numstatArgs, cwd);
2160
- const binary = /^(-\t-\t)/.test(numstat.trim());
2161
- if (binary) {
2162
- return { path, staged, patch: null, binary: true };
2564
+ async function resolveComparisonBase(cwd) {
2565
+ if (!await tryGit(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], cwd)) {
2566
+ return { sha: EMPTY_TREE_SHA, ref: null };
2163
2567
  }
2164
- const diffArgs = staged ? ["diff", "--cached", "--", path] : ["diff", "--", path];
2165
- let patch = await runGit(diffArgs, cwd);
2166
- if (patch.trim() === "" && !staged) {
2167
- patch = await runGitAllowingExitCodes(["diff", "--no-index", "--", "/dev/null", path], cwd, [
2168
- 1
2169
- ]).catch(() => "");
2170
- }
2171
- return { path, staged, patch: patch || null, binary: false };
2568
+ const defaultRef = await findDefaultBranchRef(cwd);
2569
+ if (!defaultRef) return { sha: "HEAD", ref: null };
2570
+ const mergeBase = await tryGit(["merge-base", "HEAD", defaultRef], cwd);
2571
+ return mergeBase ? { sha: mergeBase, ref: defaultRef } : { sha: "HEAD", ref: null };
2172
2572
  }
2173
2573
 
2174
2574
  // src/sessions/git/status.ts
@@ -2178,7 +2578,11 @@ async function getCurrentBranch(cwd) {
2178
2578
  return out === "" || out === "HEAD" ? null : out;
2179
2579
  } catch (err) {
2180
2580
  if (err instanceof GitNotARepoError) return null;
2181
- throw err;
2581
+ try {
2582
+ return (await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd)).trim() || null;
2583
+ } catch {
2584
+ throw err;
2585
+ }
2182
2586
  }
2183
2587
  }
2184
2588
  async function getRepositoryFullName(cwd) {
@@ -2228,88 +2632,73 @@ function parseNumstat(output) {
2228
2632
  }
2229
2633
  return map;
2230
2634
  }
2231
- var STATUS_CODE_MAP = {
2635
+ var STATUS_BY_LETTER = {
2232
2636
  A: "added",
2233
2637
  M: "modified",
2234
2638
  D: "deleted",
2235
2639
  R: "renamed",
2236
2640
  C: "copied",
2641
+ T: "modified",
2642
+ // type change, such as a file becoming a symlink
2237
2643
  U: "modified"
2238
- // unmerged — treated as modified, no conflict-resolution UI in scope
2644
+ // unmerged: no conflict-resolution UI yet, so it reads as modified
2239
2645
  };
2240
- async function getWorkspaceStatus(cwd) {
2241
- const branch = await getCurrentBranch(cwd);
2242
- if (branch === null && !await isGitRepo(cwd)) {
2243
- return { isGitRepo: false, branch: null, files: [] };
2244
- }
2245
- const [porcelain, unstagedRaw, stagedRaw] = await Promise.all([
2246
- runGit(["status", "--porcelain=v1", "-z"], cwd),
2247
- runGit(["diff", "--numstat", "-z"], cwd),
2248
- runGit(["diff", "--cached", "--numstat", "-z"], cwd)
2249
- ]);
2250
- const unstagedNumstat = parseNumstat(unstagedRaw);
2251
- const stagedNumstat = parseNumstat(stagedRaw);
2252
- const files = parsePorcelain(porcelain, unstagedNumstat, stagedNumstat);
2253
- return { isGitRepo: true, branch, files };
2254
- }
2255
- function parsePorcelain(porcelain, unstagedNumstat, stagedNumstat) {
2256
- const entries = [];
2257
- const fields = porcelain.split("\0").filter((f) => f.length > 0);
2646
+ function isStatusLetter(letter) {
2647
+ return letter in STATUS_BY_LETTER;
2648
+ }
2649
+ function parseNameStatus(output) {
2650
+ const fields = output.split("\0").filter((f) => f.length > 0);
2651
+ const changed = [];
2258
2652
  let i = 0;
2259
2653
  while (i < fields.length) {
2260
- const raw = fields[i];
2261
- const x = raw[0] ?? " ";
2262
- const y = raw[1] ?? " ";
2263
- const path = raw.slice(3);
2264
- i++;
2265
- if (x === "R" || x === "C" || y === "R" || y === "C") {
2266
- const previousPath = fields[i];
2267
- i++;
2268
- if (previousPath === void 0) continue;
2269
- pushEntriesForPath(entries, path, x, y, previousPath, unstagedNumstat, stagedNumstat);
2270
- continue;
2654
+ const letter = fields[i++][0] ?? "";
2655
+ const status = isStatusLetter(letter) ? STATUS_BY_LETTER[letter] : "modified";
2656
+ if (status === "renamed" || status === "copied") {
2657
+ const previousPath = fields[i++];
2658
+ const path = fields[i++];
2659
+ if (previousPath === void 0 || path === void 0) break;
2660
+ changed.push({ path, previousPath, status });
2661
+ } else {
2662
+ const path = fields[i++];
2663
+ if (path === void 0) break;
2664
+ changed.push({ path, status });
2271
2665
  }
2272
- pushEntriesForPath(entries, path, x, y, void 0, unstagedNumstat, stagedNumstat);
2273
2666
  }
2274
- return entries;
2667
+ return changed;
2275
2668
  }
2276
- function pushEntriesForPath(entries, path, x, y, previousPath, unstagedNumstat, stagedNumstat) {
2277
- if (x === "?" && y === "?") {
2278
- const numstat = unstagedNumstat.get(path);
2279
- entries.push({
2280
- path,
2281
- status: "untracked",
2282
- staged: false,
2283
- additions: numstat?.additions ?? null,
2284
- deletions: numstat?.deletions ?? null,
2285
- binary: numstat?.binary ?? false
2286
- });
2287
- return;
2288
- }
2289
- if (x !== " " && x !== "?") {
2290
- const numstat = stagedNumstat.get(path);
2291
- entries.push({
2292
- path,
2293
- previousPath,
2294
- status: STATUS_CODE_MAP[x] ?? "modified",
2295
- staged: true,
2296
- additions: numstat?.additions ?? null,
2297
- deletions: numstat?.deletions ?? null,
2298
- binary: numstat?.binary ?? false
2299
- });
2669
+ async function getWorkspaceStatus(cwd) {
2670
+ const branch = await getCurrentBranch(cwd);
2671
+ if (branch === null && !await isGitRepo(cwd)) {
2672
+ return { isGitRepo: false, branch: null, files: [] };
2300
2673
  }
2301
- if (y !== " " && y !== "?") {
2302
- const numstat = unstagedNumstat.get(path);
2303
- entries.push({
2304
- path,
2305
- previousPath,
2306
- status: STATUS_CODE_MAP[y] ?? "modified",
2674
+ const base = await resolveComparisonBase(cwd);
2675
+ const [nameStatusRaw, numstatRaw, untrackedRaw] = await Promise.all([
2676
+ runGitDiff(["--name-status", "-z", "-M", base.sha], cwd),
2677
+ runGitDiff(["--numstat", "-z", "-M", base.sha], cwd),
2678
+ runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
2679
+ ]);
2680
+ const numstat = parseNumstat(numstatRaw);
2681
+ const tracked = parseNameStatus(nameStatusRaw).map((changed) => {
2682
+ const counts = numstat.get(changed.path);
2683
+ return {
2684
+ path: changed.path,
2685
+ previousPath: changed.previousPath,
2686
+ status: changed.status,
2307
2687
  staged: false,
2308
- additions: numstat?.additions ?? null,
2309
- deletions: numstat?.deletions ?? null,
2310
- binary: numstat?.binary ?? false
2311
- });
2312
- }
2688
+ additions: counts?.additions ?? null,
2689
+ deletions: counts?.deletions ?? null,
2690
+ binary: counts?.binary ?? false
2691
+ };
2692
+ });
2693
+ const untracked = untrackedRaw.split("\0").filter((path) => path.length > 0).map((path) => ({
2694
+ path,
2695
+ status: "untracked",
2696
+ staged: false,
2697
+ additions: null,
2698
+ deletions: null,
2699
+ binary: false
2700
+ }));
2701
+ return { isGitRepo: true, branch, baseRef: base.ref, files: [...tracked, ...untracked] };
2313
2702
  }
2314
2703
  async function enrichSessionWithGitMetadata(session) {
2315
2704
  const [branch, repositoryFullName, status, lastActivityAt] = await Promise.all([
@@ -2328,6 +2717,33 @@ async function enrichSessionWithGitMetadata(session) {
2328
2717
  };
2329
2718
  }
2330
2719
 
2720
+ // src/sessions/git/diff.ts
2721
+ async function withRenameSource(cwd, baseSha, path) {
2722
+ const nameStatus = await runGitDiff(["--name-status", "-z", "-M", baseSha], cwd);
2723
+ const previousPath = parseNameStatus(nameStatus).find((c) => c.path === path)?.previousPath;
2724
+ return previousPath === void 0 ? [path] : [previousPath, path];
2725
+ }
2726
+ async function isUntracked(cwd, path) {
2727
+ const out = await runGit(["ls-files", "--others", "--exclude-standard", "--", path], cwd);
2728
+ return out.trim() !== "";
2729
+ }
2730
+ async function getWorkspaceDiff(cwd, path, staged) {
2731
+ const base = await resolveComparisonBase(cwd);
2732
+ const pathspec = await withRenameSource(cwd, base.sha, path);
2733
+ const numstat = await runGitDiff(["--numstat", "-M", base.sha, "--", ...pathspec], cwd);
2734
+ const binary = /^(-\t-\t)/.test(numstat.trim());
2735
+ if (binary) {
2736
+ return { path, staged, patch: null, binary: true };
2737
+ }
2738
+ let patch = await runGitDiff(["-M", base.sha, "--", ...pathspec], cwd);
2739
+ if (patch.trim() === "" && await isUntracked(cwd, path)) {
2740
+ patch = await runGitDiffAllowingExitCodes(["--no-index", "--", "/dev/null", path], cwd, [
2741
+ 1
2742
+ ]).catch(() => "");
2743
+ }
2744
+ return { path, staged, patch: patch || null, binary: false };
2745
+ }
2746
+
2331
2747
  // src/ws/server.ts
2332
2748
  import { hostname } from "os";
2333
2749
  import { WebSocketServer } from "ws";
@@ -2353,23 +2769,40 @@ function isAllowedOrigin(origin, allowlist) {
2353
2769
 
2354
2770
  // src/sessions/pty-attachment.ts
2355
2771
  import * as pty from "node-pty";
2772
+ var DEFAULT_PTY_SIZE = { cols: 80, rows: 24 };
2773
+ var MAX_SNAPSHOT_JSON_BYTES = 128 * 1024;
2774
+ function keepBottomRows(rows) {
2775
+ let budget = MAX_SNAPSHOT_JSON_BYTES;
2776
+ let first = rows.length;
2777
+ while (first > 0) {
2778
+ budget -= JSON.stringify(rows[first - 1]).length;
2779
+ if (budget < 0) break;
2780
+ first -= 1;
2781
+ }
2782
+ return rows.slice(first);
2783
+ }
2784
+ function snapshotToTerminalOutput(snapshot) {
2785
+ const rows = snapshot.replace(/\r?\n$/, "").split(/\r?\n/);
2786
+ return keepBottomRows(rows).join("\r\n");
2787
+ }
2356
2788
  var PtyAttachmentManager = class {
2357
2789
  handles = /* @__PURE__ */ new Map();
2358
2790
  /**
2359
2791
  * Attempts to attach (spawning a pty if one isn't already running for this
2360
2792
  * session). Returns false, without throwing, if the pty could not be
2361
2793
  * spawned — a spawn failure here must never take down the whole daemon
2362
- * process, since every other session's pty would die with it.
2794
+ * process, since every other session's pty would die with it. `size` only
2795
+ * applies to a pty spawned here; an existing one keeps its current size.
2363
2796
  */
2364
- async ensureAttached(sessionId, tmuxSessionName, onData) {
2797
+ async ensureAttached(sessionId, tmuxSessionName, onData, size = DEFAULT_PTY_SIZE) {
2365
2798
  let handle = this.handles.get(sessionId);
2366
2799
  if (!handle) {
2367
2800
  let ptyProcess;
2368
2801
  try {
2369
2802
  ptyProcess = pty.spawn("tmux", ["attach-session", "-t", tmuxSessionName], {
2370
2803
  name: "xterm-256color",
2371
- cols: 80,
2372
- rows: 24,
2804
+ cols: size.cols,
2805
+ rows: size.rows,
2373
2806
  cwd: process.env.HOME,
2374
2807
  env: filterSpawnEnv(process.env)
2375
2808
  });
@@ -2381,16 +2814,16 @@ ${new TmuxNotFoundError().message}
2381
2814
  }
2382
2815
  return false;
2383
2816
  }
2384
- handle = { process: ptyProcess, refCount: 0, dataListeners: /* @__PURE__ */ new Set() };
2385
- this.handles.set(sessionId, handle);
2817
+ const created = { process: ptyProcess, refCount: 0, dataListeners: /* @__PURE__ */ new Set() };
2818
+ this.handles.set(sessionId, created);
2386
2819
  ptyProcess.onData((data) => {
2387
- for (const listener of handle?.dataListeners ?? []) listener(data);
2820
+ for (const listener of created.dataListeners) listener(data);
2388
2821
  });
2389
2822
  ptyProcess.onExit(() => {
2390
- this.handles.delete(sessionId);
2823
+ if (this.handles.get(sessionId) === created) this.handles.delete(sessionId);
2391
2824
  });
2392
- handle.refCount += 1;
2393
- handle.dataListeners.add(onData);
2825
+ created.refCount += 1;
2826
+ created.dataListeners.add(onData);
2394
2827
  return true;
2395
2828
  }
2396
2829
  await this.sendPaneSnapshot(tmuxSessionName, onData);
@@ -2407,17 +2840,20 @@ ${new TmuxNotFoundError().message}
2407
2840
  handle.dataListeners.add(nextOnData);
2408
2841
  return true;
2409
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
+ }
2410
2847
  // Best-effort: a capture failure just leaves this listener blank until the next real write.
2411
2848
  async sendPaneSnapshot(tmuxSessionName, onData) {
2412
2849
  try {
2413
- onData(await capturePane(tmuxSessionName));
2850
+ onData(await this.paneSnapshot(tmuxSessionName));
2414
2851
  } catch {
2415
2852
  }
2416
2853
  }
2417
2854
  release(sessionId, onData) {
2418
2855
  const handle = this.handles.get(sessionId);
2419
- if (!handle) return;
2420
- handle.dataListeners.delete(onData);
2856
+ if (!handle?.dataListeners.delete(onData)) return;
2421
2857
  handle.refCount -= 1;
2422
2858
  if (handle.refCount <= 0) {
2423
2859
  handle.process.kill();
@@ -2442,23 +2878,84 @@ ${new TmuxNotFoundError().message}
2442
2878
  // src/sessions/session-manager.ts
2443
2879
  import { v4 as uuidv4 } from "uuid";
2444
2880
 
2881
+ // src/sessions/git/worktree.ts
2882
+ import { execFile as execFileCb3 } from "child_process";
2883
+ import { promisify as promisify3 } from "util";
2884
+ var execFile3 = promisify3(execFileCb3);
2885
+ var WorktreeBranchNameTakenError = class extends Error {
2886
+ constructor(branch) {
2887
+ super(`a branch named "${branch}" already exists`);
2888
+ this.name = "WorktreeBranchNameTakenError";
2889
+ }
2890
+ };
2891
+ var WorktreePathExistsError = class extends Error {
2892
+ constructor(path) {
2893
+ super(`worktree path already exists: ${path}`);
2894
+ this.name = "WorktreePathExistsError";
2895
+ }
2896
+ };
2897
+ var WorktreeHasUncommittedChangesError = class extends Error {
2898
+ constructor(path) {
2899
+ super(
2900
+ `worktree at ${path} has uncommitted or untracked changes \u2014 not removing it. Commit or discard the changes, then remove it yourself (git worktree remove ${path}).`
2901
+ );
2902
+ this.name = "WorktreeHasUncommittedChangesError";
2903
+ }
2904
+ };
2905
+ function stderrOf(error2) {
2906
+ if (typeof error2 !== "object" || error2 === null) return "";
2907
+ const stderr = error2.stderr;
2908
+ return typeof stderr === "string" ? stderr : "";
2909
+ }
2910
+ async function createWorktree(sourceCwd, worktreePath, branch) {
2911
+ try {
2912
+ await execFile3("git", ["worktree", "add", "-b", branch, worktreePath], { cwd: sourceCwd });
2913
+ } catch (error2) {
2914
+ const stderr = stderrOf(error2);
2915
+ if (stderr.includes("a branch named")) {
2916
+ throw new WorktreeBranchNameTakenError(branch);
2917
+ }
2918
+ if (stderr.includes("already exists")) {
2919
+ throw new WorktreePathExistsError(worktreePath);
2920
+ }
2921
+ throw error2;
2922
+ }
2923
+ }
2924
+ async function removeWorktree(sourceCwd, worktreePath) {
2925
+ try {
2926
+ await execFile3("git", ["worktree", "remove", worktreePath], { cwd: sourceCwd });
2927
+ } catch (error2) {
2928
+ const stderr = stderrOf(error2);
2929
+ if (stderr.includes("contains modified or untracked files")) {
2930
+ throw new WorktreeHasUncommittedChangesError(worktreePath);
2931
+ }
2932
+ throw error2;
2933
+ }
2934
+ }
2935
+
2445
2936
  // src/sessions/tmux/create.ts
2446
2937
  import { access } from "fs/promises";
2447
- import { homedir as homedir4 } from "os";
2448
- import { join as join4 } from "path";
2938
+ import { homedir as homedir5 } from "os";
2939
+ import { join as join5 } from "path";
2449
2940
  var InvalidCwdError = class extends Error {
2450
2941
  constructor(cwd) {
2451
2942
  super(`working directory does not exist: ${cwd}`);
2452
2943
  this.name = "InvalidCwdError";
2453
2944
  }
2454
2945
  };
2455
- function expandHome(cwd) {
2456
- if (cwd === "~") return homedir4();
2457
- if (cwd.startsWith("~/")) return join4(homedir4(), cwd.slice(2));
2458
- return cwd;
2946
+ function worktreePathFor(sessionId) {
2947
+ return join5(homedir5(), ".bondsmith", "worktrees", sessionId);
2459
2948
  }
2460
2949
  async function createTmuxSession(input) {
2461
- const cwd = expandHome(input.cwd);
2950
+ const sourceCwd = expandHome(input.cwd);
2951
+ let cwd = sourceCwd;
2952
+ let worktreeOf = null;
2953
+ if (input.worktreeBranch) {
2954
+ const worktreePath = worktreePathFor(input.id);
2955
+ await createWorktree(sourceCwd, worktreePath, input.worktreeBranch);
2956
+ cwd = worktreePath;
2957
+ worktreeOf = sourceCwd;
2958
+ }
2462
2959
  try {
2463
2960
  await access(cwd);
2464
2961
  } catch {
@@ -2474,6 +2971,9 @@ async function createTmuxSession(input) {
2474
2971
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-command", input.command]);
2475
2972
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-cwd", cwd]);
2476
2973
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-started-at", startedAt]);
2974
+ if (worktreeOf) {
2975
+ await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-worktree-of", worktreeOf]);
2976
+ }
2477
2977
  const session = {
2478
2978
  id: input.id,
2479
2979
  title: input.title,
@@ -2485,7 +2985,8 @@ async function createTmuxSession(input) {
2485
2985
  branch: null,
2486
2986
  repositoryFullName: null,
2487
2987
  lastActivityAt: null,
2488
- changedFileCount: null
2988
+ changedFileCount: null,
2989
+ worktreeOf
2489
2990
  };
2490
2991
  return enrichSessionWithGitMetadata(session);
2491
2992
  }
@@ -2494,14 +2995,14 @@ async function stopTmuxSession(tmuxSessionName) {
2494
2995
  }
2495
2996
 
2496
2997
  // src/sessions/tmux/parse.ts
2497
- var FIELD_COUNT = 6;
2998
+ var FIELD_COUNT = 7;
2498
2999
  function parseTmuxSessions(output) {
2499
3000
  const sessions = [];
2500
3001
  for (const line of output.split("\n")) {
2501
3002
  if (!line.trim()) continue;
2502
3003
  const fields = line.split(" ");
2503
3004
  if (fields.length < FIELD_COUNT) continue;
2504
- const [sessionName, id, title, command, cwd, startedAt] = fields;
3005
+ const [sessionName, id, title, command, cwd, startedAt, worktreeOf] = fields;
2505
3006
  if (!isBondsmithTmuxSessionName(sessionName)) continue;
2506
3007
  if (!id || !title || !command || !cwd || !startedAt) continue;
2507
3008
  sessions.push({
@@ -2515,7 +3016,11 @@ function parseTmuxSessions(output) {
2515
3016
  branch: null,
2516
3017
  repositoryFullName: null,
2517
3018
  lastActivityAt: null,
2518
- changedFileCount: null
3019
+ changedFileCount: null,
3020
+ // tmux reports an unset user-option as an empty string via
3021
+ // #{...}, never literally absent — a non-worktree session (the
3022
+ // common case) has no @bondsmith-worktree-of option set at all.
3023
+ worktreeOf: worktreeOf ? worktreeOf : null
2519
3024
  });
2520
3025
  }
2521
3026
  return sessions;
@@ -2528,7 +3033,8 @@ var LIST_FORMAT = [
2528
3033
  "#{@bondsmith-title}",
2529
3034
  "#{@bondsmith-command}",
2530
3035
  "#{@bondsmith-cwd}",
2531
- "#{@bondsmith-started-at}"
3036
+ "#{@bondsmith-started-at}",
3037
+ "#{@bondsmith-worktree-of}"
2532
3038
  ].join(" ");
2533
3039
  async function discoverSessions() {
2534
3040
  try {
@@ -2584,6 +3090,17 @@ var SessionManager = class {
2584
3090
  await stopTmuxSession(session.tmuxSessionName);
2585
3091
  } catch {
2586
3092
  }
3093
+ if (session.worktreeOf) {
3094
+ try {
3095
+ await removeWorktree(session.worktreeOf, session.cwd);
3096
+ } catch (error2) {
3097
+ console.error("Failed to remove session worktree", {
3098
+ sessionId: session.id,
3099
+ worktreePath: session.cwd,
3100
+ error: error2
3101
+ });
3102
+ }
3103
+ }
2587
3104
  return this.removeAsExited(session);
2588
3105
  }
2589
3106
  /**
@@ -2609,7 +3126,12 @@ var SessionManager = class {
2609
3126
  if (error2 instanceof TmuxNotFoundError) console.error(`
2610
3127
  ${error2.message}
2611
3128
  `);
2612
- if (!(error2 instanceof TmuxNoServerRunningError)) return;
3129
+ if (!(error2 instanceof TmuxNoServerRunningError)) {
3130
+ if (!(error2 instanceof TmuxNotFoundError)) {
3131
+ console.error("tmux list-sessions failed; skipping reconcile", error2);
3132
+ }
3133
+ return;
3134
+ }
2613
3135
  }
2614
3136
  await Promise.all(
2615
3137
  Array.from(this.sessions.values()).map(async (session) => {
@@ -2683,6 +3205,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2683
3205
  const rejectionReasonByRequest = /* @__PURE__ */ new WeakMap();
2684
3206
  const wss = new WebSocketServer({
2685
3207
  server: httpServer,
3208
+ // ws defaults to 100 MiB per frame, parsed before any signature is checked.
3209
+ maxPayload: MAX_WIRE_MESSAGE_BYTES,
2686
3210
  // Client always sends the dev-tunnel token as the sole entry (see
2687
3211
  // isValidDevTunnelToken); echoing it back is required for a spec-
2688
3212
  // compliant handshake once a Sec-WebSocket-Protocol is offered.
@@ -2706,6 +3230,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2706
3230
  broadcast({ type: "session.updated", session });
2707
3231
  });
2708
3232
  wss.on("connection", (socket, request) => {
3233
+ socket.on("error", (error2) => {
3234
+ console.warn("bondsmithd: browser socket error", error2.message);
3235
+ });
2709
3236
  const rejectionReason = rejectionReasonByRequest.get(request);
2710
3237
  if (rejectionReason) {
2711
3238
  rejectionReasonByRequest.delete(request);
@@ -2720,7 +3247,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2720
3247
  if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
2721
3248
  },
2722
3249
  (controllerId) => registerControllerConnection(controllerId, socket),
2723
- ptyAttachments
3250
+ ptyAttachments,
3251
+ () => socket.bufferedAmount
2724
3252
  );
2725
3253
  cleanupBySocket.set(socket, releaseAllSubscriptions);
2726
3254
  connectionAuthBySocket.set(socket, connectionAuth);
@@ -2731,7 +3259,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2731
3259
  challenge: challenge.challenge,
2732
3260
  daemonFingerprint,
2733
3261
  hostname: hostname(),
2734
- expiresAt: challenge.expiresAt
3262
+ expiresAt: challenge.expiresAt,
3263
+ protocolVersion: PROTOCOL_VERSION,
3264
+ daemonVersion: DAEMON_VERSION
2735
3265
  });
2736
3266
  socket.on("message", (raw) => {
2737
3267
  let parsed;
@@ -2747,6 +3277,7 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2747
3277
  broadcast,
2748
3278
  getWorkspaceStatus,
2749
3279
  getWorkspaceDiff,
3280
+ validateProjectPath,
2750
3281
  hostname: () => hostname(),
2751
3282
  daemonFingerprint,
2752
3283
  pendingAuthorizations,
@@ -2781,15 +3312,20 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2781
3312
  }
2782
3313
 
2783
3314
  // src/index.ts
3315
+ if (isVersionRequest(process.argv.slice(2))) {
3316
+ console.log(versionLine());
3317
+ process.exit(0);
3318
+ }
2784
3319
  function loadRepoRootEnv() {
2785
- const repoRoot = join5(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
2786
- if (!existsSync(join5(repoRoot, "pnpm-workspace.yaml"))) return;
2787
- const envLocal = join5(repoRoot, ".env.local");
2788
- const envDefault = join5(repoRoot, ".env");
3320
+ const repoRoot = join6(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
3321
+ if (!existsSync(join6(repoRoot, "pnpm-workspace.yaml"))) return;
3322
+ const envLocal = join6(repoRoot, ".env.local");
3323
+ const envDefault = join6(repoRoot, ".env");
2789
3324
  loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
2790
3325
  }
2791
3326
  loadRepoRootEnv();
2792
3327
  async function start() {
3328
+ console.log(versionLine());
2793
3329
  let config = getDaemonConfig();
2794
3330
  if (!config.devTunnelToken) {
2795
3331
  const generatedToken = await loadOrCreateDevTunnelToken();
@@ -2838,6 +3374,7 @@ async function start() {
2838
3374
  ptyAttachments,
2839
3375
  getWorkspaceStatus,
2840
3376
  getWorkspaceDiff,
3377
+ validateProjectPath,
2841
3378
  hostname: hostname2,
2842
3379
  pendingAuthorizations,
2843
3380
  presentPendingAuthorization