@bondsmith/bondsmithd 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +14 -0
  2. package/dist/index.js +342 -110
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -43,6 +43,20 @@ BONDSMITH_RELAY_URL=ws://127.0.0.1:8787 # defaults to production; set this
43
43
  `BONDSMITH_DEV_TUNNEL_TOKEN` also exists but only applies to the legacy direct-connection path
44
44
  (`BONDSMITH_RELAY_ENABLED=false`) — should not be set by hand for a normal install.
45
45
 
46
+ ## Updating
47
+
48
+ Stop the running daemon, then update it and start it again:
49
+
50
+ ```bash
51
+ npm install -g @bondsmith/bondsmithd@latest
52
+ bondsmithd
53
+ ```
54
+
55
+ If you run it with `npx`, use `npx @bondsmith/bondsmithd@latest` so a cached older copy isn't reused.
56
+
57
+ When the web app and an older daemon disagree, Bondsmith shows "couldn't read a message from your
58
+ daemon". Reload the page first; if that doesn't fix it, update the daemon as above.
59
+
46
60
  ## What it does
47
61
 
48
62
  - Creates, lists, attaches to, and stops tmux sessions prefixed `bondsmith-*`.
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 = 1;
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";
@@ -55,7 +68,8 @@ var localSessionSchema = z2.object({
55
68
  branch: z2.string().nullable(),
56
69
  repositoryFullName: z2.string().nullable(),
57
70
  lastActivityAt: z2.string().nullable(),
58
- changedFileCount: z2.number().int().nonnegative().nullable()
71
+ changedFileCount: z2.number().int().nonnegative().nullable(),
72
+ worktreeOf: z2.string().nullable()
59
73
  });
60
74
  var workspaceFileEntrySchema = z2.object({
61
75
  path: z2.string(),
@@ -77,6 +91,10 @@ var workspaceFileDiffSchema = z2.object({
77
91
  patch: z2.string().nullable(),
78
92
  binary: z2.boolean()
79
93
  });
94
+ var projectPathValidationSchema = z2.discriminatedUnion("valid", [
95
+ z2.object({ valid: z2.literal(true), path: z2.string() }),
96
+ z2.object({ valid: z2.literal(false), reason: z2.enum(PROJECT_PATH_INVALID_REASONS) })
97
+ ]);
80
98
  var browserMessageSchema = z2.discriminatedUnion("type", [
81
99
  z2.object({ type: z2.literal("session.list") }),
82
100
  z2.object({
@@ -84,9 +102,34 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
84
102
  title: z2.string().min(1).max(200),
85
103
  cwd: z2.string().min(1),
86
104
  command: z2.string().min(1).max(2e3),
105
+ // Opt-in worktree creation — see messages.ts's own field comment.
106
+ // Most branch-name legality is left to git itself in createWorktree()
107
+ // (whose named error classes surface a rejection back to the
108
+ // browser), EXCEPT a leading "-": git's own argv parsing for
109
+ // `worktree add -b <branch>` treats a leading "-" as a flag, not a
110
+ // branch name, producing a raw multi-line `git branch` usage dump as
111
+ // the error instead of a clean, classified one (confirmed live
112
+ // during adversarial review) — rejected here instead, before it ever
113
+ // reaches git.
114
+ worktreeBranch: z2.string().min(1).max(250).refine((value) => !value.startsWith("-"), {
115
+ message: "branch name can't start with -"
116
+ }).optional(),
117
+ requestId: z2.string().min(1).max(200).optional()
118
+ }),
119
+ z2.object({
120
+ type: z2.literal("session.attach"),
121
+ sessionId: z2.string(),
122
+ // Optional (not required) so an older daemon build, or a stale queued
123
+ // envelope signed before this field existed, still parses — see
124
+ // session.attached/error's own requestId fields for why this matters:
125
+ // without a requestId round-tripped on both the ack and the error
126
+ // path, the browser can only guess which pending attachSession() call
127
+ // an incoming error/ack belongs to (oldest-pending heuristic), which
128
+ // misattributes a daemon error for ANY other in-flight command
129
+ // (observed: a session.resize racing ahead of its own session.attach)
130
+ // to the wrong promise — a confirmed bug, not a hypothetical.
87
131
  requestId: z2.string().min(1).max(200).optional()
88
132
  }),
89
- z2.object({ type: z2.literal("session.attach"), sessionId: z2.string() }),
90
133
  z2.object({
91
134
  type: z2.literal("session.input"),
92
135
  sessionId: z2.string(),
@@ -116,6 +159,11 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
116
159
  staged: z2.boolean(),
117
160
  requestId: z2.string().min(1).max(200)
118
161
  }),
162
+ z2.object({
163
+ type: z2.literal("project.validatePath"),
164
+ path: z2.string().min(1).max(4096),
165
+ requestId: z2.string().min(1).max(200)
166
+ }),
119
167
  z2.object({ type: z2.literal("daemon.getInfo") }),
120
168
  // Second-controller enrollment (ADR-011/ADR-012, BD-433) — deliberately
121
169
  // unsigned: the requesting browser has no trusted key yet to sign with.
@@ -166,8 +214,22 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
166
214
  // session" (observed during a reconnect: attachSession() was
167
215
  // fire-and-forget, so terminal.onData could fire before attach
168
216
  // completed).
169
- z2.object({ type: z2.literal("session.attached"), sessionId: z2.string() }),
170
- z2.object({ type: z2.literal("error"), message: z2.string() }),
217
+ z2.object({
218
+ type: z2.literal("session.attached"),
219
+ sessionId: z2.string(),
220
+ requestId: z2.string().optional()
221
+ }),
222
+ z2.object({
223
+ type: z2.literal("error"),
224
+ message: z2.string(),
225
+ // Echoes the triggering message's requestId when it had one, so the
226
+ // browser can match this error to the exact pending request instead
227
+ // of guessing "oldest pending create, then oldest pending attach" —
228
+ // undefined for error paths with no requestId to echo (most session.*
229
+ // commands still don't carry one; see session.attach above for the
230
+ // one this was added to fix).
231
+ requestId: z2.string().optional()
232
+ }),
171
233
  z2.object({
172
234
  type: z2.literal("workspace.status"),
173
235
  sessionId: z2.string(),
@@ -180,6 +242,11 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
180
242
  requestId: z2.string(),
181
243
  diff: workspaceFileDiffSchema
182
244
  }),
245
+ z2.object({
246
+ type: z2.literal("project.pathValidated"),
247
+ requestId: z2.string(),
248
+ result: projectPathValidationSchema
249
+ }),
183
250
  z2.object({
184
251
  type: z2.literal("daemon.info"),
185
252
  hostname: z2.string(),
@@ -199,7 +266,10 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
199
266
  challenge: z2.string().min(1),
200
267
  daemonFingerprint: z2.string().min(1),
201
268
  hostname: z2.string(),
202
- expiresAt: z2.number().int().positive()
269
+ expiresAt: z2.number().int().positive(),
270
+ // .catch(undefined): a malformed informational field must never fail the whole handshake message.
271
+ protocolVersion: z2.number().int().positive().optional().catch(void 0),
272
+ daemonVersion: z2.string().optional().catch(void 0)
203
273
  }),
204
274
  z2.object({
205
275
  type: z2.literal("connection.authenticated"),
@@ -284,7 +354,8 @@ var SIGNABLE_ACTIONS = [
284
354
  "session.stop",
285
355
  "session.list",
286
356
  "workspace.getStatus",
287
- "workspace.getDiff"
357
+ "workspace.getDiff",
358
+ "project.validatePath"
288
359
  ];
289
360
  var envelopeSchema = z3.object({
290
361
  version: z3.literal(1),
@@ -613,7 +684,7 @@ async function callTRPCQuery(baseUrl, procedure, input) {
613
684
  // src/pairing/pairing.ts
614
685
  var DEFAULT_POLL_INTERVAL_MS = 3e3;
615
686
  function sleep(ms) {
616
- return new Promise((resolve) => setTimeout(resolve, ms));
687
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
617
688
  }
618
689
  async function ensurePaired(options) {
619
690
  const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
@@ -750,14 +821,101 @@ async function bindFirstController(params, response) {
750
821
  }
751
822
  }
752
823
 
824
+ // src/projects/validate-path.ts
825
+ import { stat } from "fs/promises";
826
+ import { isAbsolute, resolve } from "path";
827
+
828
+ // src/sessions/expand-home.ts
829
+ import { homedir } from "os";
830
+ import { join } from "path";
831
+ function expandHome(cwd) {
832
+ if (cwd === "~") return homedir();
833
+ if (cwd.startsWith("~/")) return join(homedir(), cwd.slice(2));
834
+ return cwd;
835
+ }
836
+
837
+ // src/sessions/git/client.ts
838
+ import { execFile as execFileCb } from "child_process";
839
+ import { promisify } from "util";
840
+ var execFile = promisify(execFileCb);
841
+ var GitNotARepoError = class extends Error {
842
+ constructor() {
843
+ super("not a git repository");
844
+ this.name = "GitNotARepoError";
845
+ }
846
+ };
847
+ function isNotARepoError(error2) {
848
+ if (typeof error2 !== "object" || error2 === null) return false;
849
+ const stderr = error2.stderr;
850
+ return typeof stderr === "string" && stderr.includes("not a git repository");
851
+ }
852
+ async function runGit(args, cwd) {
853
+ try {
854
+ const { stdout } = await execFile("git", args, { cwd });
855
+ return stdout;
856
+ } catch (error2) {
857
+ if (isNotARepoError(error2)) throw new GitNotARepoError();
858
+ throw error2;
859
+ }
860
+ }
861
+ async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
862
+ try {
863
+ const { stdout } = await execFile("git", args, { cwd });
864
+ return stdout;
865
+ } catch (error2) {
866
+ if (isNotARepoError(error2)) throw new GitNotARepoError();
867
+ const code = error2.code;
868
+ const stdout = error2.stdout;
869
+ if (typeof code === "number" && allowedExitCodes.includes(code) && typeof stdout === "string") {
870
+ return stdout;
871
+ }
872
+ throw error2;
873
+ }
874
+ }
875
+
876
+ // src/projects/validate-path.ts
877
+ var NOT_FOUND_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
878
+ var PERMISSION_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM"]);
879
+ function invalidReasonForFsError(error2) {
880
+ const code = error2?.code;
881
+ if (typeof code !== "string") return null;
882
+ if (NOT_FOUND_CODES.has(code)) return "not_found";
883
+ if (PERMISSION_CODES.has(code)) return "permission_denied";
884
+ return null;
885
+ }
886
+ async function validateProjectPath(input) {
887
+ const expanded = expandHome(input.trim());
888
+ if (!isAbsolute(expanded)) return { valid: false, reason: "relative_path" };
889
+ const path = resolve(expanded);
890
+ try {
891
+ const stats = await stat(path);
892
+ if (!stats.isDirectory()) return { valid: false, reason: "not_a_directory" };
893
+ } catch (error2) {
894
+ const reason = invalidReasonForFsError(error2);
895
+ if (reason) return { valid: false, reason };
896
+ throw error2;
897
+ }
898
+ try {
899
+ const insideWorkTree = (await runGit(["rev-parse", "--is-inside-work-tree"], path)).trim();
900
+ if (insideWorkTree !== "true") return { valid: false, reason: "not_a_git_repo" };
901
+ } catch (error2) {
902
+ if (error2 instanceof GitNotARepoError) return { valid: false, reason: "not_a_git_repo" };
903
+ throw error2;
904
+ }
905
+ return { valid: true, path };
906
+ }
907
+
753
908
  // src/relay/relay-client.ts
754
909
  import { sign } from "crypto";
755
910
  import WebSocket from "ws";
756
911
 
912
+ // src/version.ts
913
+ var DAEMON_VERSION = true ? "0.1.3" : "dev";
914
+
757
915
  // src/sessions/tmux/client.ts
758
- import { execFile as execFileCb } from "child_process";
759
- import { promisify } from "util";
760
- var execFile = promisify(execFileCb);
916
+ import { execFile as execFileCb2 } from "child_process";
917
+ import { promisify as promisify2 } from "util";
918
+ var execFile2 = promisify2(execFileCb2);
761
919
  var TmuxNoServerRunningError = class extends Error {
762
920
  constructor() {
763
921
  super("no tmux server running");
@@ -793,7 +951,7 @@ function filterSpawnEnv(env) {
793
951
  }
794
952
  async function runTmux(args, options) {
795
953
  try {
796
- const { stdout } = await execFile("tmux", args, { ...options, encoding: "utf8" });
954
+ const { stdout } = await execFile2("tmux", args, { ...options, encoding: "utf8" });
797
955
  return stdout;
798
956
  } catch (error2) {
799
957
  if (error2?.code === "ENOENT") throw new TmuxNotFoundError();
@@ -834,8 +992,8 @@ import { verify as verify2 } from "crypto";
834
992
 
835
993
  // src/security/trusted-controllers.ts
836
994
  import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
837
- import { homedir } from "os";
838
- import { dirname, join } from "path";
995
+ import { homedir as homedir2 } from "os";
996
+ import { dirname, join as join2 } from "path";
839
997
  import { z as z9 } from "zod";
840
998
  var trustedControllerSchema = z9.object({
841
999
  id: z9.string(),
@@ -858,7 +1016,7 @@ var trustedControllersFileSchema = z9.object({
858
1016
  controllers: z9.array(trustedControllerSchema)
859
1017
  });
860
1018
  function defaultTrustedControllersPath() {
861
- return join(homedir(), ".bondsmith", "trusted-controllers.json");
1019
+ return join2(homedir2(), ".bondsmith", "trusted-controllers.json");
862
1020
  }
863
1021
  async function loadTrustedControllers(path = defaultTrustedControllersPath()) {
864
1022
  let raw;
@@ -1036,6 +1194,7 @@ var BRACKETED_PASTE_END = "\x1B[201~";
1036
1194
  function createRouterConnection(connectionAuth, send, markControllerId, ptyAttachments) {
1037
1195
  const outputListeners = /* @__PURE__ */ new Map();
1038
1196
  const attachLocks = /* @__PURE__ */ new Map();
1197
+ let admissionChain = Promise.resolve();
1039
1198
  const conn = {
1040
1199
  subscriptions: /* @__PURE__ */ new Set(),
1041
1200
  send,
@@ -1045,6 +1204,13 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
1045
1204
  getOutputListener: (sessionId) => outputListeners.get(sessionId),
1046
1205
  connectionAuth,
1047
1206
  markControllerId,
1207
+ admitInOrder: (fn) => {
1208
+ const next = admissionChain.catch(() => {
1209
+ }).then(fn);
1210
+ admissionChain = next.catch(() => {
1211
+ });
1212
+ return next;
1213
+ },
1048
1214
  withAttachLock: (sessionId, fn) => {
1049
1215
  const previous = attachLocks.get(sessionId) ?? Promise.resolve();
1050
1216
  const next = previous.catch(() => {
@@ -1065,8 +1231,8 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
1065
1231
  }
1066
1232
  return { conn, releaseAllSubscriptions };
1067
1233
  }
1068
- function error(message) {
1069
- return { type: "error", message };
1234
+ function error(message, requestId) {
1235
+ return { type: "error", message, requestId };
1070
1236
  }
1071
1237
  function errorMessageOf(err, fallback) {
1072
1238
  return err instanceof Error ? err.message : fallback;
@@ -1082,35 +1248,39 @@ async function handleBrowserMessage(conn, raw, deps) {
1082
1248
  const { envelope } = signedCommand.data;
1083
1249
  const innerMessage = browserMessageSchema.safeParse(envelope.payload);
1084
1250
  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));
1251
+ conn.send(error(INVALID_MESSAGE_ERROR));
1100
1252
  return;
1101
1253
  }
1254
+ const admitted = await conn.admitInOrder(async () => {
1255
+ const verdict = await verifyCommand(
1256
+ signedCommand.data,
1257
+ deps.daemonFingerprint,
1258
+ conn.connectionAuth,
1259
+ deps.trustedControllersPath
1260
+ );
1261
+ if (!verdict.ok) {
1262
+ conn.send(error(verifyRejectionMessage(verdict.reason)));
1263
+ return false;
1264
+ }
1265
+ if (conn.connectionAuth.isRevoked()) {
1266
+ conn.send(error(REVOKED_REJECTION_MESSAGE));
1267
+ return false;
1268
+ }
1269
+ return true;
1270
+ });
1271
+ if (!admitted) return;
1102
1272
  await dispatch(conn, innerMessage.data, deps);
1103
1273
  return;
1104
1274
  }
1105
1275
  const parsed = browserMessageSchema.safeParse(raw);
1106
1276
  if (!parsed.success) {
1107
- conn.send(error("invalid message"));
1277
+ conn.send(error(INVALID_MESSAGE_ERROR));
1108
1278
  return;
1109
1279
  }
1110
1280
  if (parsed.data.type === "connection.authenticate") {
1111
- const result = await conn.connectionAuth.authenticate(
1112
- parsed.data.signed,
1113
- deps.trustedControllersPath
1281
+ const { signed } = parsed.data;
1282
+ const result = await conn.admitInOrder(
1283
+ () => conn.connectionAuth.authenticate(signed, deps.trustedControllersPath)
1114
1284
  );
1115
1285
  if (!result.ok) {
1116
1286
  conn.send({ type: "connection.authenticationFailed", reason: result.reason });
@@ -1132,6 +1302,7 @@ async function dispatch(conn, message, {
1132
1302
  broadcast,
1133
1303
  getWorkspaceStatus: getWorkspaceStatus2,
1134
1304
  getWorkspaceDiff: getWorkspaceDiff2,
1305
+ validateProjectPath: validateProjectPath2,
1135
1306
  hostname: hostname3,
1136
1307
  daemonFingerprint,
1137
1308
  pendingAuthorizations,
@@ -1143,22 +1314,26 @@ async function dispatch(conn, message, {
1143
1314
  return;
1144
1315
  }
1145
1316
  case "session.create": {
1146
- const { title, cwd, command, requestId } = message;
1317
+ const { title, cwd, command, worktreeBranch, requestId } = message;
1147
1318
  try {
1148
- const session = await sessionManager.create({ title, cwd, command });
1319
+ const session = await sessionManager.create({ title, cwd, command, worktreeBranch });
1149
1320
  broadcast({ type: "session.created", session, requestId });
1150
1321
  } catch (err) {
1151
- conn.send(error(errorMessageOf(err, "failed to create session")));
1322
+ conn.send(error(errorMessageOf(err, "failed to create session"), requestId));
1152
1323
  }
1153
1324
  return;
1154
1325
  }
1155
1326
  case "session.attach": {
1327
+ const { requestId } = message;
1156
1328
  if (!isValidSessionId(message.sessionId)) {
1157
- conn.send(error("invalid session id"));
1329
+ conn.send(error("invalid session id", requestId));
1330
+ return;
1331
+ }
1332
+ const session = sessionManager.get(message.sessionId);
1333
+ if (!session) {
1334
+ conn.send(error("unknown session", requestId));
1158
1335
  return;
1159
1336
  }
1160
- const session = getSessionOrError(sessionManager, conn, message.sessionId);
1161
- if (!session) return;
1162
1337
  await conn.withAttachLock(message.sessionId, async () => {
1163
1338
  const previousListener = conn.getOutputListener(message.sessionId);
1164
1339
  const onData = (data) => {
@@ -1172,7 +1347,7 @@ async function dispatch(conn, message, {
1172
1347
  )) {
1173
1348
  conn.subscriptions.add(message.sessionId);
1174
1349
  conn.registerOutputListener(message.sessionId, onData);
1175
- conn.send({ type: "session.attached", sessionId: message.sessionId });
1350
+ conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
1176
1351
  return;
1177
1352
  }
1178
1353
  if (previousListener) {
@@ -1184,12 +1359,12 @@ async function dispatch(conn, message, {
1184
1359
  onData
1185
1360
  );
1186
1361
  if (!attached) {
1187
- conn.send(error("failed to attach to session"));
1362
+ conn.send(error("failed to attach to session", requestId));
1188
1363
  return;
1189
1364
  }
1190
1365
  conn.subscriptions.add(message.sessionId);
1191
1366
  conn.registerOutputListener(message.sessionId, onData);
1192
- conn.send({ type: "session.attached", sessionId: message.sessionId });
1367
+ conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
1193
1368
  });
1194
1369
  return;
1195
1370
  }
@@ -1265,6 +1440,15 @@ async function dispatch(conn, message, {
1265
1440
  }
1266
1441
  return;
1267
1442
  }
1443
+ case "project.validatePath": {
1444
+ try {
1445
+ const result = await validateProjectPath2(message.path);
1446
+ conn.send({ type: "project.pathValidated", requestId: message.requestId, result });
1447
+ } catch (err) {
1448
+ conn.send(error(errorMessageOf(err, "failed to validate project path"), message.requestId));
1449
+ }
1450
+ return;
1451
+ }
1268
1452
  case "daemon.getInfo": {
1269
1453
  conn.send({ type: "daemon.info", hostname: hostname3(), daemonFingerprint });
1270
1454
  return;
@@ -1615,7 +1799,9 @@ function startRelayClient(deps) {
1615
1799
  challenge: challenge.challenge,
1616
1800
  daemonFingerprint: deps.daemonFingerprint,
1617
1801
  hostname: deps.hostname(),
1618
- expiresAt: challenge.expiresAt
1802
+ expiresAt: challenge.expiresAt,
1803
+ protocolVersion: PROTOCOL_VERSION,
1804
+ daemonVersion: DAEMON_VERSION
1619
1805
  });
1620
1806
  }
1621
1807
  function handleFrame(frame) {
@@ -1713,10 +1899,10 @@ function startRelayClient(deps) {
1713
1899
  // src/security/daemon-identity.ts
1714
1900
  import { generateKeyPairSync, sign as sign2, verify as verify4 } from "crypto";
1715
1901
  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";
1902
+ import { homedir as homedir3 } from "os";
1903
+ import { dirname as dirname2, join as join3 } from "path";
1718
1904
  function defaultCredentialsPath() {
1719
- return join2(homedir2(), ".bondsmith", "credentials");
1905
+ return join3(homedir3(), ".bondsmith", "credentials");
1720
1906
  }
1721
1907
  async function loadOrCreateDaemonIdentity(credentialsPath = defaultCredentialsPath()) {
1722
1908
  const existing = await tryReadIdentity(credentialsPath);
@@ -1778,10 +1964,10 @@ function extractPemBlock(text, label) {
1778
1964
  // src/security/dev-tunnel-token-file.ts
1779
1965
  import { randomBytes as randomBytes2 } from "crypto";
1780
1966
  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";
1967
+ import { homedir as homedir4 } from "os";
1968
+ import { dirname as dirname3, join as join4 } from "path";
1783
1969
  function defaultDevTunnelTokenPath() {
1784
- return join3(homedir3(), ".bondsmith", "dev-tunnel-token");
1970
+ return join4(homedir4(), ".bondsmith", "dev-tunnel-token");
1785
1971
  }
1786
1972
  async function loadOrCreateDevTunnelToken(tokenPath = defaultDevTunnelTokenPath()) {
1787
1973
  const existing = await tryReadToken(tokenPath);
@@ -1889,8 +2075,8 @@ var PendingAuthorizationStore = class {
1889
2075
  requestToken: randomUUID()
1890
2076
  };
1891
2077
  let settleFn;
1892
- const resultPromise = new Promise((resolve) => {
1893
- settleFn = resolve;
2078
+ const resultPromise = new Promise((resolve2) => {
2079
+ settleFn = resolve2;
1894
2080
  });
1895
2081
  const entry = {
1896
2082
  authorization,
@@ -2114,45 +2300,6 @@ function presentPendingAuthorizationOnTerminal(store) {
2114
2300
  };
2115
2301
  }
2116
2302
 
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) {
2133
- 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;
2139
- }
2140
- }
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;
2151
- }
2152
- throw error2;
2153
- }
2154
- }
2155
-
2156
2303
  // src/sessions/git/diff.ts
2157
2304
  async function getWorkspaceDiff(cwd, path, staged) {
2158
2305
  const numstatArgs = staged ? ["diff", "--cached", "--numstat", "--", path] : ["diff", "--numstat", "--", path];
@@ -2442,23 +2589,84 @@ ${new TmuxNotFoundError().message}
2442
2589
  // src/sessions/session-manager.ts
2443
2590
  import { v4 as uuidv4 } from "uuid";
2444
2591
 
2592
+ // src/sessions/git/worktree.ts
2593
+ import { execFile as execFileCb3 } from "child_process";
2594
+ import { promisify as promisify3 } from "util";
2595
+ var execFile3 = promisify3(execFileCb3);
2596
+ var WorktreeBranchNameTakenError = class extends Error {
2597
+ constructor(branch) {
2598
+ super(`a branch named "${branch}" already exists`);
2599
+ this.name = "WorktreeBranchNameTakenError";
2600
+ }
2601
+ };
2602
+ var WorktreePathExistsError = class extends Error {
2603
+ constructor(path) {
2604
+ super(`worktree path already exists: ${path}`);
2605
+ this.name = "WorktreePathExistsError";
2606
+ }
2607
+ };
2608
+ var WorktreeHasUncommittedChangesError = class extends Error {
2609
+ constructor(path) {
2610
+ super(
2611
+ `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}).`
2612
+ );
2613
+ this.name = "WorktreeHasUncommittedChangesError";
2614
+ }
2615
+ };
2616
+ function stderrOf(error2) {
2617
+ if (typeof error2 !== "object" || error2 === null) return "";
2618
+ const stderr = error2.stderr;
2619
+ return typeof stderr === "string" ? stderr : "";
2620
+ }
2621
+ async function createWorktree(sourceCwd, worktreePath, branch) {
2622
+ try {
2623
+ await execFile3("git", ["worktree", "add", "-b", branch, worktreePath], { cwd: sourceCwd });
2624
+ } catch (error2) {
2625
+ const stderr = stderrOf(error2);
2626
+ if (stderr.includes("a branch named")) {
2627
+ throw new WorktreeBranchNameTakenError(branch);
2628
+ }
2629
+ if (stderr.includes("already exists")) {
2630
+ throw new WorktreePathExistsError(worktreePath);
2631
+ }
2632
+ throw error2;
2633
+ }
2634
+ }
2635
+ async function removeWorktree(sourceCwd, worktreePath) {
2636
+ try {
2637
+ await execFile3("git", ["worktree", "remove", worktreePath], { cwd: sourceCwd });
2638
+ } catch (error2) {
2639
+ const stderr = stderrOf(error2);
2640
+ if (stderr.includes("contains modified or untracked files")) {
2641
+ throw new WorktreeHasUncommittedChangesError(worktreePath);
2642
+ }
2643
+ throw error2;
2644
+ }
2645
+ }
2646
+
2445
2647
  // src/sessions/tmux/create.ts
2446
2648
  import { access } from "fs/promises";
2447
- import { homedir as homedir4 } from "os";
2448
- import { join as join4 } from "path";
2649
+ import { homedir as homedir5 } from "os";
2650
+ import { join as join5 } from "path";
2449
2651
  var InvalidCwdError = class extends Error {
2450
2652
  constructor(cwd) {
2451
2653
  super(`working directory does not exist: ${cwd}`);
2452
2654
  this.name = "InvalidCwdError";
2453
2655
  }
2454
2656
  };
2455
- function expandHome(cwd) {
2456
- if (cwd === "~") return homedir4();
2457
- if (cwd.startsWith("~/")) return join4(homedir4(), cwd.slice(2));
2458
- return cwd;
2657
+ function worktreePathFor(sessionId) {
2658
+ return join5(homedir5(), ".bondsmith", "worktrees", sessionId);
2459
2659
  }
2460
2660
  async function createTmuxSession(input) {
2461
- const cwd = expandHome(input.cwd);
2661
+ const sourceCwd = expandHome(input.cwd);
2662
+ let cwd = sourceCwd;
2663
+ let worktreeOf = null;
2664
+ if (input.worktreeBranch) {
2665
+ const worktreePath = worktreePathFor(input.id);
2666
+ await createWorktree(sourceCwd, worktreePath, input.worktreeBranch);
2667
+ cwd = worktreePath;
2668
+ worktreeOf = sourceCwd;
2669
+ }
2462
2670
  try {
2463
2671
  await access(cwd);
2464
2672
  } catch {
@@ -2474,6 +2682,9 @@ async function createTmuxSession(input) {
2474
2682
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-command", input.command]);
2475
2683
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-cwd", cwd]);
2476
2684
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-started-at", startedAt]);
2685
+ if (worktreeOf) {
2686
+ await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-worktree-of", worktreeOf]);
2687
+ }
2477
2688
  const session = {
2478
2689
  id: input.id,
2479
2690
  title: input.title,
@@ -2485,7 +2696,8 @@ async function createTmuxSession(input) {
2485
2696
  branch: null,
2486
2697
  repositoryFullName: null,
2487
2698
  lastActivityAt: null,
2488
- changedFileCount: null
2699
+ changedFileCount: null,
2700
+ worktreeOf
2489
2701
  };
2490
2702
  return enrichSessionWithGitMetadata(session);
2491
2703
  }
@@ -2494,14 +2706,14 @@ async function stopTmuxSession(tmuxSessionName) {
2494
2706
  }
2495
2707
 
2496
2708
  // src/sessions/tmux/parse.ts
2497
- var FIELD_COUNT = 6;
2709
+ var FIELD_COUNT = 7;
2498
2710
  function parseTmuxSessions(output) {
2499
2711
  const sessions = [];
2500
2712
  for (const line of output.split("\n")) {
2501
2713
  if (!line.trim()) continue;
2502
2714
  const fields = line.split(" ");
2503
2715
  if (fields.length < FIELD_COUNT) continue;
2504
- const [sessionName, id, title, command, cwd, startedAt] = fields;
2716
+ const [sessionName, id, title, command, cwd, startedAt, worktreeOf] = fields;
2505
2717
  if (!isBondsmithTmuxSessionName(sessionName)) continue;
2506
2718
  if (!id || !title || !command || !cwd || !startedAt) continue;
2507
2719
  sessions.push({
@@ -2515,7 +2727,11 @@ function parseTmuxSessions(output) {
2515
2727
  branch: null,
2516
2728
  repositoryFullName: null,
2517
2729
  lastActivityAt: null,
2518
- changedFileCount: null
2730
+ changedFileCount: null,
2731
+ // tmux reports an unset user-option as an empty string via
2732
+ // #{...}, never literally absent — a non-worktree session (the
2733
+ // common case) has no @bondsmith-worktree-of option set at all.
2734
+ worktreeOf: worktreeOf ? worktreeOf : null
2519
2735
  });
2520
2736
  }
2521
2737
  return sessions;
@@ -2528,7 +2744,8 @@ var LIST_FORMAT = [
2528
2744
  "#{@bondsmith-title}",
2529
2745
  "#{@bondsmith-command}",
2530
2746
  "#{@bondsmith-cwd}",
2531
- "#{@bondsmith-started-at}"
2747
+ "#{@bondsmith-started-at}",
2748
+ "#{@bondsmith-worktree-of}"
2532
2749
  ].join(" ");
2533
2750
  async function discoverSessions() {
2534
2751
  try {
@@ -2584,6 +2801,17 @@ var SessionManager = class {
2584
2801
  await stopTmuxSession(session.tmuxSessionName);
2585
2802
  } catch {
2586
2803
  }
2804
+ if (session.worktreeOf) {
2805
+ try {
2806
+ await removeWorktree(session.worktreeOf, session.cwd);
2807
+ } catch (error2) {
2808
+ console.error("Failed to remove session worktree", {
2809
+ sessionId: session.id,
2810
+ worktreePath: session.cwd,
2811
+ error: error2
2812
+ });
2813
+ }
2814
+ }
2587
2815
  return this.removeAsExited(session);
2588
2816
  }
2589
2817
  /**
@@ -2731,7 +2959,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2731
2959
  challenge: challenge.challenge,
2732
2960
  daemonFingerprint,
2733
2961
  hostname: hostname(),
2734
- expiresAt: challenge.expiresAt
2962
+ expiresAt: challenge.expiresAt,
2963
+ protocolVersion: PROTOCOL_VERSION,
2964
+ daemonVersion: DAEMON_VERSION
2735
2965
  });
2736
2966
  socket.on("message", (raw) => {
2737
2967
  let parsed;
@@ -2747,6 +2977,7 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2747
2977
  broadcast,
2748
2978
  getWorkspaceStatus,
2749
2979
  getWorkspaceDiff,
2980
+ validateProjectPath,
2750
2981
  hostname: () => hostname(),
2751
2982
  daemonFingerprint,
2752
2983
  pendingAuthorizations,
@@ -2782,10 +3013,10 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2782
3013
 
2783
3014
  // src/index.ts
2784
3015
  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");
3016
+ const repoRoot = join6(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
3017
+ if (!existsSync(join6(repoRoot, "pnpm-workspace.yaml"))) return;
3018
+ const envLocal = join6(repoRoot, ".env.local");
3019
+ const envDefault = join6(repoRoot, ".env");
2789
3020
  loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
2790
3021
  }
2791
3022
  loadRepoRootEnv();
@@ -2838,6 +3069,7 @@ async function start() {
2838
3069
  ptyAttachments,
2839
3070
  getWorkspaceStatus,
2840
3071
  getWorkspaceDiff,
3072
+ validateProjectPath,
2841
3073
  hostname: hostname2,
2842
3074
  pendingAuthorizations,
2843
3075
  presentPendingAuthorization
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bondsmith/bondsmithd",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",