@bondsmith/bondsmithd 0.1.1 → 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 +20 -5
  2. package/dist/index.js +396 -123
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -37,12 +37,26 @@ Environment variables (all optional):
37
37
  ```
38
38
  PORT=4577 # defaults to 4577
39
39
  ALLOWED_ORIGINS=https://app.bondsmith.dev
40
- BONDSMITH_RELAY_URL=wss://relay.bondsmith.dev # defaults to a local-only address; set this for a real install
40
+ BONDSMITH_RELAY_URL=ws://127.0.0.1:8787 # defaults to production; set this when developing bondsmithd against a local apps/relay
41
41
  ```
42
42
 
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-*`.
@@ -54,10 +68,11 @@ BONDSMITH_RELAY_URL=wss://relay.bondsmith.dev # defaults to a local-only address
54
68
 
55
69
  ## Production connectivity (current status)
56
70
 
57
- `bondsmithd` connects outbound to the Bondsmith relay by default (`BONDSMITH_RELAY_ENABLED=true`),
58
- which is what a production account actually uses — no dev-tunnel token involved. Point
59
- `BONDSMITH_RELAY_URL` at `wss://relay.bondsmith.dev` for a real install (see "Environment
60
- variables" above); it defaults to a local-only address for monorepo development.
71
+ `bondsmithd` connects outbound to the Bondsmith relay by default (`BONDSMITH_RELAY_ENABLED=true`,
72
+ `BONDSMITH_RELAY_URL=wss://relay.bondsmith.dev`), which is what a production account actually
73
+ uses — no dev-tunnel token involved, no config needed for a real install. Override
74
+ `BONDSMITH_RELAY_URL` to a local apps/relay instance (see "Environment variables" above) only when
75
+ developing bondsmithd itself.
61
76
 
62
77
  The dev-tunnel-token requirement only applies to the legacy direct-connection path
63
78
  (`BONDSMITH_RELAY_ENABLED=false`), which still 404s the token-handoff route in production by
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),
@@ -508,18 +579,19 @@ var environmentSchema = z8.object({
508
579
  // install needs to touch.
509
580
  BONDSMITH_BIND_HOST: z8.string().min(1).default("127.0.0.1"),
510
581
  // BD-450/BD-483/BD-484: connect outbound to the Bondsmith relay instead of
511
- // (or alongside) listening inbound. ON by default — local dev is meant to
512
- // mirror production's actual topology (browser -> relay -> daemon), not
513
- // diverge from it, so plain `pnpm --filter @bondsmith/bondsmithd dev`
514
- // connects outbound to a locally-running apps/relay (its own `pnpm dev`
515
- // on the default port below) exactly like a real deployment would.
516
- // BONDSMITH_RELAY_URL defaults to that same local apps/relay instance for
517
- // the same reason. The existing inbound listener is completely
518
- // unaffected either way — it keeps running regardless, so direct-
519
- // connection debugging (bypassing the relay entirely) is still possible
520
- // by setting BONDSMITH_RELAY_ENABLED=false explicitly.
582
+ // (or alongside) listening inbound. ON by default. The existing inbound
583
+ // listener is completely unaffected either way — it keeps running
584
+ // regardless, so direct-connection debugging (bypassing the relay
585
+ // entirely) is still possible by setting BONDSMITH_RELAY_ENABLED=false
586
+ // explicitly.
521
587
  BONDSMITH_RELAY_ENABLED: z8.string().default("true").transform((value) => value === "true" || value === "1"),
522
- BONDSMITH_RELAY_URL: z8.string().url().default("ws://127.0.0.1:8787")
588
+ // Defaults to production, mirroring BONDSMITH_API_URL above, so a real
589
+ // install works with zero config — a bare `bondsmithd` used to silently
590
+ // connect outbound to loopback (nothing listening there) with no error,
591
+ // looking healthy while never reaching relay at all. Override to a local
592
+ // apps/relay instance (its own `pnpm dev` default port) when developing
593
+ // bondsmithd itself against a local relay.
594
+ BONDSMITH_RELAY_URL: z8.string().url().default("wss://relay.bondsmith.dev")
523
595
  });
524
596
  function getDaemonConfig(env = process.env) {
525
597
  const parsed = environmentSchema.parse(env);
@@ -612,7 +684,7 @@ async function callTRPCQuery(baseUrl, procedure, input) {
612
684
  // src/pairing/pairing.ts
613
685
  var DEFAULT_POLL_INTERVAL_MS = 3e3;
614
686
  function sleep(ms) {
615
- return new Promise((resolve) => setTimeout(resolve, ms));
687
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
616
688
  }
617
689
  async function ensurePaired(options) {
618
690
  const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
@@ -749,20 +821,128 @@ async function bindFirstController(params, response) {
749
821
  }
750
822
  }
751
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
+
752
908
  // src/relay/relay-client.ts
753
909
  import { sign } from "crypto";
754
910
  import WebSocket from "ws";
755
911
 
912
+ // src/version.ts
913
+ var DAEMON_VERSION = true ? "0.1.3" : "dev";
914
+
756
915
  // src/sessions/tmux/client.ts
757
- import { execFile as execFileCb } from "child_process";
758
- import { promisify } from "util";
759
- var execFile = promisify(execFileCb);
916
+ import { execFile as execFileCb2 } from "child_process";
917
+ import { promisify as promisify2 } from "util";
918
+ var execFile2 = promisify2(execFileCb2);
760
919
  var TmuxNoServerRunningError = class extends Error {
761
920
  constructor() {
762
921
  super("no tmux server running");
763
922
  this.name = "TmuxNoServerRunningError";
764
923
  }
765
924
  };
925
+ var TmuxNotFoundError = class extends Error {
926
+ constructor() {
927
+ super(
928
+ [
929
+ "bondsmithd requires tmux, and it was not found on PATH.",
930
+ "",
931
+ "Install it:",
932
+ " macOS (Homebrew): brew install tmux",
933
+ " Debian/Ubuntu: sudo apt-get install tmux",
934
+ " Fedora/RHEL: sudo dnf install tmux",
935
+ " Arch: sudo pacman -S tmux",
936
+ "",
937
+ "If tmux is already installed, start bondsmithd from a normal interactive shell \u2014",
938
+ "not a LaunchAgent, Docker container, or other minimal-PATH context. Those often",
939
+ "don't inherit the PATH tmux was installed into (e.g. Homebrew's /opt/homebrew/bin",
940
+ "on Apple Silicon)."
941
+ ].join("\n")
942
+ );
943
+ this.name = "TmuxNotFoundError";
944
+ }
945
+ };
766
946
  var SENSITIVE_ENV_KEYS = ["ANTHROPIC_API_KEY", "BONDSMITH_DEV_TUNNEL_TOKEN"];
767
947
  function filterSpawnEnv(env) {
768
948
  const filtered = { ...env };
@@ -770,8 +950,13 @@ function filterSpawnEnv(env) {
770
950
  return filtered;
771
951
  }
772
952
  async function runTmux(args, options) {
773
- const { stdout } = await execFile("tmux", args, { ...options, encoding: "utf8" });
774
- return stdout;
953
+ try {
954
+ const { stdout } = await execFile2("tmux", args, { ...options, encoding: "utf8" });
955
+ return stdout;
956
+ } catch (error2) {
957
+ if (error2?.code === "ENOENT") throw new TmuxNotFoundError();
958
+ throw error2;
959
+ }
775
960
  }
776
961
  async function sendKeysLiteral(tmuxSessionName, text) {
777
962
  await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text]);
@@ -807,8 +992,8 @@ import { verify as verify2 } from "crypto";
807
992
 
808
993
  // src/security/trusted-controllers.ts
809
994
  import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
810
- import { homedir } from "os";
811
- import { dirname, join } from "path";
995
+ import { homedir as homedir2 } from "os";
996
+ import { dirname, join as join2 } from "path";
812
997
  import { z as z9 } from "zod";
813
998
  var trustedControllerSchema = z9.object({
814
999
  id: z9.string(),
@@ -831,7 +1016,7 @@ var trustedControllersFileSchema = z9.object({
831
1016
  controllers: z9.array(trustedControllerSchema)
832
1017
  });
833
1018
  function defaultTrustedControllersPath() {
834
- return join(homedir(), ".bondsmith", "trusted-controllers.json");
1019
+ return join2(homedir2(), ".bondsmith", "trusted-controllers.json");
835
1020
  }
836
1021
  async function loadTrustedControllers(path = defaultTrustedControllersPath()) {
837
1022
  let raw;
@@ -1009,6 +1194,7 @@ var BRACKETED_PASTE_END = "\x1B[201~";
1009
1194
  function createRouterConnection(connectionAuth, send, markControllerId, ptyAttachments) {
1010
1195
  const outputListeners = /* @__PURE__ */ new Map();
1011
1196
  const attachLocks = /* @__PURE__ */ new Map();
1197
+ let admissionChain = Promise.resolve();
1012
1198
  const conn = {
1013
1199
  subscriptions: /* @__PURE__ */ new Set(),
1014
1200
  send,
@@ -1018,6 +1204,13 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
1018
1204
  getOutputListener: (sessionId) => outputListeners.get(sessionId),
1019
1205
  connectionAuth,
1020
1206
  markControllerId,
1207
+ admitInOrder: (fn) => {
1208
+ const next = admissionChain.catch(() => {
1209
+ }).then(fn);
1210
+ admissionChain = next.catch(() => {
1211
+ });
1212
+ return next;
1213
+ },
1021
1214
  withAttachLock: (sessionId, fn) => {
1022
1215
  const previous = attachLocks.get(sessionId) ?? Promise.resolve();
1023
1216
  const next = previous.catch(() => {
@@ -1038,8 +1231,8 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
1038
1231
  }
1039
1232
  return { conn, releaseAllSubscriptions };
1040
1233
  }
1041
- function error(message) {
1042
- return { type: "error", message };
1234
+ function error(message, requestId) {
1235
+ return { type: "error", message, requestId };
1043
1236
  }
1044
1237
  function errorMessageOf(err, fallback) {
1045
1238
  return err instanceof Error ? err.message : fallback;
@@ -1055,35 +1248,39 @@ async function handleBrowserMessage(conn, raw, deps) {
1055
1248
  const { envelope } = signedCommand.data;
1056
1249
  const innerMessage = browserMessageSchema.safeParse(envelope.payload);
1057
1250
  if (!innerMessage.success || innerMessage.data.type !== envelope.action) {
1058
- conn.send(error("invalid message"));
1059
- return;
1060
- }
1061
- const verdict = await verifyCommand(
1062
- signedCommand.data,
1063
- deps.daemonFingerprint,
1064
- conn.connectionAuth,
1065
- deps.trustedControllersPath
1066
- );
1067
- if (!verdict.ok) {
1068
- conn.send(error(verifyRejectionMessage(verdict.reason)));
1069
- return;
1070
- }
1071
- if (conn.connectionAuth.isRevoked()) {
1072
- conn.send(error(REVOKED_REJECTION_MESSAGE));
1251
+ conn.send(error(INVALID_MESSAGE_ERROR));
1073
1252
  return;
1074
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;
1075
1272
  await dispatch(conn, innerMessage.data, deps);
1076
1273
  return;
1077
1274
  }
1078
1275
  const parsed = browserMessageSchema.safeParse(raw);
1079
1276
  if (!parsed.success) {
1080
- conn.send(error("invalid message"));
1277
+ conn.send(error(INVALID_MESSAGE_ERROR));
1081
1278
  return;
1082
1279
  }
1083
1280
  if (parsed.data.type === "connection.authenticate") {
1084
- const result = await conn.connectionAuth.authenticate(
1085
- parsed.data.signed,
1086
- deps.trustedControllersPath
1281
+ const { signed } = parsed.data;
1282
+ const result = await conn.admitInOrder(
1283
+ () => conn.connectionAuth.authenticate(signed, deps.trustedControllersPath)
1087
1284
  );
1088
1285
  if (!result.ok) {
1089
1286
  conn.send({ type: "connection.authenticationFailed", reason: result.reason });
@@ -1105,6 +1302,7 @@ async function dispatch(conn, message, {
1105
1302
  broadcast,
1106
1303
  getWorkspaceStatus: getWorkspaceStatus2,
1107
1304
  getWorkspaceDiff: getWorkspaceDiff2,
1305
+ validateProjectPath: validateProjectPath2,
1108
1306
  hostname: hostname3,
1109
1307
  daemonFingerprint,
1110
1308
  pendingAuthorizations,
@@ -1116,22 +1314,26 @@ async function dispatch(conn, message, {
1116
1314
  return;
1117
1315
  }
1118
1316
  case "session.create": {
1119
- const { title, cwd, command, requestId } = message;
1317
+ const { title, cwd, command, worktreeBranch, requestId } = message;
1120
1318
  try {
1121
- const session = await sessionManager.create({ title, cwd, command });
1319
+ const session = await sessionManager.create({ title, cwd, command, worktreeBranch });
1122
1320
  broadcast({ type: "session.created", session, requestId });
1123
1321
  } catch (err) {
1124
- conn.send(error(errorMessageOf(err, "failed to create session")));
1322
+ conn.send(error(errorMessageOf(err, "failed to create session"), requestId));
1125
1323
  }
1126
1324
  return;
1127
1325
  }
1128
1326
  case "session.attach": {
1327
+ const { requestId } = message;
1129
1328
  if (!isValidSessionId(message.sessionId)) {
1130
- 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));
1131
1335
  return;
1132
1336
  }
1133
- const session = getSessionOrError(sessionManager, conn, message.sessionId);
1134
- if (!session) return;
1135
1337
  await conn.withAttachLock(message.sessionId, async () => {
1136
1338
  const previousListener = conn.getOutputListener(message.sessionId);
1137
1339
  const onData = (data) => {
@@ -1145,7 +1347,7 @@ async function dispatch(conn, message, {
1145
1347
  )) {
1146
1348
  conn.subscriptions.add(message.sessionId);
1147
1349
  conn.registerOutputListener(message.sessionId, onData);
1148
- conn.send({ type: "session.attached", sessionId: message.sessionId });
1350
+ conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
1149
1351
  return;
1150
1352
  }
1151
1353
  if (previousListener) {
@@ -1157,12 +1359,12 @@ async function dispatch(conn, message, {
1157
1359
  onData
1158
1360
  );
1159
1361
  if (!attached) {
1160
- conn.send(error("failed to attach to session"));
1362
+ conn.send(error("failed to attach to session", requestId));
1161
1363
  return;
1162
1364
  }
1163
1365
  conn.subscriptions.add(message.sessionId);
1164
1366
  conn.registerOutputListener(message.sessionId, onData);
1165
- conn.send({ type: "session.attached", sessionId: message.sessionId });
1367
+ conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
1166
1368
  });
1167
1369
  return;
1168
1370
  }
@@ -1238,6 +1440,15 @@ async function dispatch(conn, message, {
1238
1440
  }
1239
1441
  return;
1240
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
+ }
1241
1452
  case "daemon.getInfo": {
1242
1453
  conn.send({ type: "daemon.info", hostname: hostname3(), daemonFingerprint });
1243
1454
  return;
@@ -1588,7 +1799,9 @@ function startRelayClient(deps) {
1588
1799
  challenge: challenge.challenge,
1589
1800
  daemonFingerprint: deps.daemonFingerprint,
1590
1801
  hostname: deps.hostname(),
1591
- expiresAt: challenge.expiresAt
1802
+ expiresAt: challenge.expiresAt,
1803
+ protocolVersion: PROTOCOL_VERSION,
1804
+ daemonVersion: DAEMON_VERSION
1592
1805
  });
1593
1806
  }
1594
1807
  function handleFrame(frame) {
@@ -1686,10 +1899,10 @@ function startRelayClient(deps) {
1686
1899
  // src/security/daemon-identity.ts
1687
1900
  import { generateKeyPairSync, sign as sign2, verify as verify4 } from "crypto";
1688
1901
  import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1689
- import { homedir as homedir2 } from "os";
1690
- 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";
1691
1904
  function defaultCredentialsPath() {
1692
- return join2(homedir2(), ".bondsmith", "credentials");
1905
+ return join3(homedir3(), ".bondsmith", "credentials");
1693
1906
  }
1694
1907
  async function loadOrCreateDaemonIdentity(credentialsPath = defaultCredentialsPath()) {
1695
1908
  const existing = await tryReadIdentity(credentialsPath);
@@ -1751,10 +1964,10 @@ function extractPemBlock(text, label) {
1751
1964
  // src/security/dev-tunnel-token-file.ts
1752
1965
  import { randomBytes as randomBytes2 } from "crypto";
1753
1966
  import { mkdir as mkdir3, readFile as readFile3, rename as rename3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
1754
- import { homedir as homedir3 } from "os";
1755
- 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";
1756
1969
  function defaultDevTunnelTokenPath() {
1757
- return join3(homedir3(), ".bondsmith", "dev-tunnel-token");
1970
+ return join4(homedir4(), ".bondsmith", "dev-tunnel-token");
1758
1971
  }
1759
1972
  async function loadOrCreateDevTunnelToken(tokenPath = defaultDevTunnelTokenPath()) {
1760
1973
  const existing = await tryReadToken(tokenPath);
@@ -1862,8 +2075,8 @@ var PendingAuthorizationStore = class {
1862
2075
  requestToken: randomUUID()
1863
2076
  };
1864
2077
  let settleFn;
1865
- const resultPromise = new Promise((resolve) => {
1866
- settleFn = resolve;
2078
+ const resultPromise = new Promise((resolve2) => {
2079
+ settleFn = resolve2;
1867
2080
  });
1868
2081
  const entry = {
1869
2082
  authorization,
@@ -2087,45 +2300,6 @@ function presentPendingAuthorizationOnTerminal(store) {
2087
2300
  };
2088
2301
  }
2089
2302
 
2090
- // src/sessions/git/client.ts
2091
- import { execFile as execFileCb2 } from "child_process";
2092
- import { promisify as promisify2 } from "util";
2093
- var execFile2 = promisify2(execFileCb2);
2094
- var GitNotARepoError = class extends Error {
2095
- constructor() {
2096
- super("not a git repository");
2097
- this.name = "GitNotARepoError";
2098
- }
2099
- };
2100
- function isNotARepoError(error2) {
2101
- if (typeof error2 !== "object" || error2 === null) return false;
2102
- const stderr = error2.stderr;
2103
- return typeof stderr === "string" && stderr.includes("not a git repository");
2104
- }
2105
- async function runGit(args, cwd) {
2106
- try {
2107
- const { stdout } = await execFile2("git", args, { cwd });
2108
- return stdout;
2109
- } catch (error2) {
2110
- if (isNotARepoError(error2)) throw new GitNotARepoError();
2111
- throw error2;
2112
- }
2113
- }
2114
- async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
2115
- try {
2116
- const { stdout } = await execFile2("git", args, { cwd });
2117
- return stdout;
2118
- } catch (error2) {
2119
- if (isNotARepoError(error2)) throw new GitNotARepoError();
2120
- const code = error2.code;
2121
- const stdout = error2.stdout;
2122
- if (typeof code === "number" && allowedExitCodes.includes(code) && typeof stdout === "string") {
2123
- return stdout;
2124
- }
2125
- throw error2;
2126
- }
2127
- }
2128
-
2129
2303
  // src/sessions/git/diff.ts
2130
2304
  async function getWorkspaceDiff(cwd, path, staged) {
2131
2305
  const numstatArgs = staged ? ["diff", "--cached", "--numstat", "--", path] : ["diff", "--numstat", "--", path];
@@ -2346,7 +2520,12 @@ var PtyAttachmentManager = class {
2346
2520
  cwd: process.env.HOME,
2347
2521
  env: filterSpawnEnv(process.env)
2348
2522
  });
2349
- } catch {
2523
+ } catch (error2) {
2524
+ if (error2?.code === "ENOENT") {
2525
+ console.error(`
2526
+ ${new TmuxNotFoundError().message}
2527
+ `);
2528
+ }
2350
2529
  return false;
2351
2530
  }
2352
2531
  handle = { process: ptyProcess, refCount: 0, dataListeners: /* @__PURE__ */ new Set() };
@@ -2410,23 +2589,84 @@ var PtyAttachmentManager = class {
2410
2589
  // src/sessions/session-manager.ts
2411
2590
  import { v4 as uuidv4 } from "uuid";
2412
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
+
2413
2647
  // src/sessions/tmux/create.ts
2414
2648
  import { access } from "fs/promises";
2415
- import { homedir as homedir4 } from "os";
2416
- import { join as join4 } from "path";
2649
+ import { homedir as homedir5 } from "os";
2650
+ import { join as join5 } from "path";
2417
2651
  var InvalidCwdError = class extends Error {
2418
2652
  constructor(cwd) {
2419
2653
  super(`working directory does not exist: ${cwd}`);
2420
2654
  this.name = "InvalidCwdError";
2421
2655
  }
2422
2656
  };
2423
- function expandHome(cwd) {
2424
- if (cwd === "~") return homedir4();
2425
- if (cwd.startsWith("~/")) return join4(homedir4(), cwd.slice(2));
2426
- return cwd;
2657
+ function worktreePathFor(sessionId) {
2658
+ return join5(homedir5(), ".bondsmith", "worktrees", sessionId);
2427
2659
  }
2428
2660
  async function createTmuxSession(input) {
2429
- 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
+ }
2430
2670
  try {
2431
2671
  await access(cwd);
2432
2672
  } catch {
@@ -2442,6 +2682,9 @@ async function createTmuxSession(input) {
2442
2682
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-command", input.command]);
2443
2683
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-cwd", cwd]);
2444
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
+ }
2445
2688
  const session = {
2446
2689
  id: input.id,
2447
2690
  title: input.title,
@@ -2453,7 +2696,8 @@ async function createTmuxSession(input) {
2453
2696
  branch: null,
2454
2697
  repositoryFullName: null,
2455
2698
  lastActivityAt: null,
2456
- changedFileCount: null
2699
+ changedFileCount: null,
2700
+ worktreeOf
2457
2701
  };
2458
2702
  return enrichSessionWithGitMetadata(session);
2459
2703
  }
@@ -2462,14 +2706,14 @@ async function stopTmuxSession(tmuxSessionName) {
2462
2706
  }
2463
2707
 
2464
2708
  // src/sessions/tmux/parse.ts
2465
- var FIELD_COUNT = 6;
2709
+ var FIELD_COUNT = 7;
2466
2710
  function parseTmuxSessions(output) {
2467
2711
  const sessions = [];
2468
2712
  for (const line of output.split("\n")) {
2469
2713
  if (!line.trim()) continue;
2470
2714
  const fields = line.split(" ");
2471
2715
  if (fields.length < FIELD_COUNT) continue;
2472
- const [sessionName, id, title, command, cwd, startedAt] = fields;
2716
+ const [sessionName, id, title, command, cwd, startedAt, worktreeOf] = fields;
2473
2717
  if (!isBondsmithTmuxSessionName(sessionName)) continue;
2474
2718
  if (!id || !title || !command || !cwd || !startedAt) continue;
2475
2719
  sessions.push({
@@ -2483,7 +2727,11 @@ function parseTmuxSessions(output) {
2483
2727
  branch: null,
2484
2728
  repositoryFullName: null,
2485
2729
  lastActivityAt: null,
2486
- 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
2487
2735
  });
2488
2736
  }
2489
2737
  return sessions;
@@ -2496,7 +2744,8 @@ var LIST_FORMAT = [
2496
2744
  "#{@bondsmith-title}",
2497
2745
  "#{@bondsmith-command}",
2498
2746
  "#{@bondsmith-cwd}",
2499
- "#{@bondsmith-started-at}"
2747
+ "#{@bondsmith-started-at}",
2748
+ "#{@bondsmith-worktree-of}"
2500
2749
  ].join(" ");
2501
2750
  async function discoverSessions() {
2502
2751
  try {
@@ -2505,6 +2754,12 @@ async function discoverSessions() {
2505
2754
  return Promise.all(sessions.map((session) => enrichSessionWithGitMetadata(session)));
2506
2755
  } catch (error2) {
2507
2756
  if (error2 instanceof TmuxNoServerRunningError) return [];
2757
+ if (error2 instanceof TmuxNotFoundError) {
2758
+ console.error(`
2759
+ ${error2.message}
2760
+ `);
2761
+ process.exit(1);
2762
+ }
2508
2763
  throw error2;
2509
2764
  }
2510
2765
  }
@@ -2546,6 +2801,17 @@ var SessionManager = class {
2546
2801
  await stopTmuxSession(session.tmuxSessionName);
2547
2802
  } catch {
2548
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
+ }
2549
2815
  return this.removeAsExited(session);
2550
2816
  }
2551
2817
  /**
@@ -2568,6 +2834,9 @@ var SessionManager = class {
2568
2834
  const output = await listTmuxSessions("#{session_name}");
2569
2835
  liveSessionNames = new Set(output.split("\n").filter((name) => name.length > 0));
2570
2836
  } catch (error2) {
2837
+ if (error2 instanceof TmuxNotFoundError) console.error(`
2838
+ ${error2.message}
2839
+ `);
2571
2840
  if (!(error2 instanceof TmuxNoServerRunningError)) return;
2572
2841
  }
2573
2842
  await Promise.all(
@@ -2690,7 +2959,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2690
2959
  challenge: challenge.challenge,
2691
2960
  daemonFingerprint,
2692
2961
  hostname: hostname(),
2693
- expiresAt: challenge.expiresAt
2962
+ expiresAt: challenge.expiresAt,
2963
+ protocolVersion: PROTOCOL_VERSION,
2964
+ daemonVersion: DAEMON_VERSION
2694
2965
  });
2695
2966
  socket.on("message", (raw) => {
2696
2967
  let parsed;
@@ -2706,6 +2977,7 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2706
2977
  broadcast,
2707
2978
  getWorkspaceStatus,
2708
2979
  getWorkspaceDiff,
2980
+ validateProjectPath,
2709
2981
  hostname: () => hostname(),
2710
2982
  daemonFingerprint,
2711
2983
  pendingAuthorizations,
@@ -2741,10 +3013,10 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
2741
3013
 
2742
3014
  // src/index.ts
2743
3015
  function loadRepoRootEnv() {
2744
- const repoRoot = join5(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
2745
- if (!existsSync(join5(repoRoot, "pnpm-workspace.yaml"))) return;
2746
- const envLocal = join5(repoRoot, ".env.local");
2747
- 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");
2748
3020
  loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
2749
3021
  }
2750
3022
  loadRepoRootEnv();
@@ -2797,6 +3069,7 @@ async function start() {
2797
3069
  ptyAttachments,
2798
3070
  getWorkspaceStatus,
2799
3071
  getWorkspaceDiff,
3072
+ validateProjectPath,
2800
3073
  hostname: hostname2,
2801
3074
  pendingAuthorizations,
2802
3075
  presentPendingAuthorization
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bondsmith/bondsmithd",
3
- "version": "0.1.1",
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",