@bondsmith/bondsmithd 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +197 -30
  2. 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 join6 } from "path";
7
+ import { dirname as dirname4, join as join7 } from "path";
8
8
  import { fileURLToPath } from "url";
9
9
 
10
10
  // ../../packages/local-agent-protocol/src/session.ts
@@ -951,7 +951,7 @@ import { sign } from "crypto";
951
951
  import WebSocket from "ws";
952
952
 
953
953
  // src/version.ts
954
- var DAEMON_VERSION = true ? "0.1.5" : "dev";
954
+ var DAEMON_VERSION = true ? "0.1.6" : "dev";
955
955
  var VERSION_FLAGS = ["--version", "-v"];
956
956
  function versionLine() {
957
957
  return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
@@ -1577,7 +1577,10 @@ async function dispatch(conn, message, {
1577
1577
  const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
1578
1578
  if (!session) return;
1579
1579
  try {
1580
- const status = await getWorkspaceStatus2(session.cwd);
1580
+ const status = await getWorkspaceStatus2(
1581
+ session.cwd,
1582
+ sessionManager.getBaseline(session.id)
1583
+ );
1581
1584
  conn.send({
1582
1585
  type: "workspace.status",
1583
1586
  sessionId: message.sessionId,
@@ -1593,7 +1596,12 @@ async function dispatch(conn, message, {
1593
1596
  const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
1594
1597
  if (!session) return;
1595
1598
  try {
1596
- const diff = await getWorkspaceDiff2(session.cwd, message.path, message.staged);
1599
+ const diff = await getWorkspaceDiff2(
1600
+ session.cwd,
1601
+ message.path,
1602
+ message.staged,
1603
+ sessionManager.getBaseline(session.id)
1604
+ );
1597
1605
  conn.send({
1598
1606
  type: "workspace.diff",
1599
1607
  sessionId: message.sessionId,
@@ -2541,6 +2549,10 @@ function presentPendingAuthorizationOnTerminal(store) {
2541
2549
  };
2542
2550
  }
2543
2551
 
2552
+ // src/sessions/git/baseline.ts
2553
+ import { lstat } from "fs/promises";
2554
+ import { join as join5 } from "path";
2555
+
2544
2556
  // src/sessions/git/base.ts
2545
2557
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
2546
2558
  var DEFAULT_BRANCH_CANDIDATES = ["origin/main", "origin/master", "main", "master"];
@@ -2574,6 +2586,107 @@ async function resolveComparisonBase(cwd) {
2574
2586
  return mergeBase ? { sha: mergeBase, ref: defaultRef } : { sha: "HEAD", ref: null };
2575
2587
  }
2576
2588
 
2589
+ // src/sessions/git/baseline.ts
2590
+ var SESSION_START_REF = "session start";
2591
+ var MAX_PREEXISTING_FILES = 200;
2592
+ var DELETED = "deleted";
2593
+ async function fingerprintFile(cwd, path) {
2594
+ try {
2595
+ const stats = await lstat(join5(cwd, path));
2596
+ return `${stats.size}:${Math.trunc(stats.mtimeMs)}`;
2597
+ } catch {
2598
+ return DELETED;
2599
+ }
2600
+ }
2601
+ async function headSha(cwd) {
2602
+ try {
2603
+ return (await runGit(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], cwd)).trim() || null;
2604
+ } catch {
2605
+ return null;
2606
+ }
2607
+ }
2608
+ async function listChangedPaths(cwd, baseSha) {
2609
+ const [tracked, untracked] = await Promise.all([
2610
+ runGitDiff(["--relative", "--name-only", "-z", baseSha], cwd),
2611
+ runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
2612
+ ]);
2613
+ const paths = [...tracked.split("\0"), ...untracked.split("\0")].filter((p) => p.length > 0);
2614
+ return [...new Set(paths)];
2615
+ }
2616
+ async function captureBaseline(cwd, options) {
2617
+ try {
2618
+ await runGit(["rev-parse", "--is-inside-work-tree"], cwd);
2619
+ } catch {
2620
+ return null;
2621
+ }
2622
+ try {
2623
+ const baseSha = await headSha(cwd);
2624
+ const preexisting = /* @__PURE__ */ new Map();
2625
+ if (!options.freshCheckout) {
2626
+ const changed = await listChangedPaths(cwd, baseSha ?? EMPTY_TREE_SHA);
2627
+ for (const path of changed.slice(0, MAX_PREEXISTING_FILES)) {
2628
+ preexisting.set(path, await fingerprintFile(cwd, path));
2629
+ }
2630
+ }
2631
+ return { baseSha, preexisting };
2632
+ } catch (error2) {
2633
+ console.error("Failed to take a session baseline; comparing against the default branch", {
2634
+ cwd,
2635
+ error: error2
2636
+ });
2637
+ return null;
2638
+ }
2639
+ }
2640
+ async function commitExists(cwd, sha) {
2641
+ try {
2642
+ await runGit(["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], cwd);
2643
+ return true;
2644
+ } catch {
2645
+ return false;
2646
+ }
2647
+ }
2648
+ async function resolveBaseFor(cwd, baseline) {
2649
+ if (!baseline) return resolveComparisonBase(cwd);
2650
+ if (baseline.baseSha !== null && !await commitExists(cwd, baseline.baseSha)) {
2651
+ return resolveComparisonBase(cwd);
2652
+ }
2653
+ return { sha: baseline.baseSha ?? EMPTY_TREE_SHA, ref: SESSION_START_REF };
2654
+ }
2655
+ async function dropPreexisting(cwd, entries, baseline) {
2656
+ if (baseline.preexisting.size === 0) return entries;
2657
+ const untouched = await Promise.all(
2658
+ entries.map(async (entry) => {
2659
+ const before = baseline.preexisting.get(entry.path);
2660
+ return before !== void 0 && before === await fingerprintFile(cwd, entry.path);
2661
+ })
2662
+ );
2663
+ return entries.filter((_, index) => !untouched[index]);
2664
+ }
2665
+ function encodeBaseline(baseline) {
2666
+ const serialized = {
2667
+ v: 1,
2668
+ base: baseline.baseSha,
2669
+ files: [...baseline.preexisting]
2670
+ };
2671
+ return Buffer.from(JSON.stringify(serialized), "utf8").toString("base64url");
2672
+ }
2673
+ function isSerializedBaseline(value) {
2674
+ if (typeof value !== "object" || value === null) return false;
2675
+ const candidate = value;
2676
+ return candidate.v === 1 && (candidate.base === null || typeof candidate.base === "string") && Array.isArray(candidate.files) && candidate.files.every(
2677
+ (file) => Array.isArray(file) && file.length === 2 && typeof file[0] === "string" && typeof file[1] === "string"
2678
+ );
2679
+ }
2680
+ function decodeBaseline(encoded) {
2681
+ try {
2682
+ const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
2683
+ if (!isSerializedBaseline(parsed)) return null;
2684
+ return { baseSha: parsed.base, preexisting: new Map(parsed.files) };
2685
+ } catch {
2686
+ return null;
2687
+ }
2688
+ }
2689
+
2577
2690
  // src/sessions/git/status.ts
2578
2691
  async function getCurrentBranch(cwd) {
2579
2692
  try {
@@ -2669,15 +2782,15 @@ function parseNameStatus(output) {
2669
2782
  }
2670
2783
  return changed;
2671
2784
  }
2672
- async function getWorkspaceStatus(cwd) {
2785
+ async function getWorkspaceStatus(cwd, baseline) {
2673
2786
  const branch = await getCurrentBranch(cwd);
2674
2787
  if (branch === null && !await isGitRepo(cwd)) {
2675
2788
  return { isGitRepo: false, branch: null, files: [] };
2676
2789
  }
2677
- const base = await resolveComparisonBase(cwd);
2790
+ const base = await resolveBaseFor(cwd, baseline);
2678
2791
  const [nameStatusRaw, numstatRaw, untrackedRaw] = await Promise.all([
2679
- runGitDiff(["--name-status", "-z", "-M", base.sha], cwd),
2680
- runGitDiff(["--numstat", "-z", "-M", base.sha], cwd),
2792
+ runGitDiff(["--relative", "--name-status", "-z", "-M", base.sha], cwd),
2793
+ runGitDiff(["--relative", "--numstat", "-z", "-M", base.sha], cwd),
2681
2794
  runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
2682
2795
  ]);
2683
2796
  const numstat = parseNumstat(numstatRaw);
@@ -2701,13 +2814,19 @@ async function getWorkspaceStatus(cwd) {
2701
2814
  deletions: null,
2702
2815
  binary: false
2703
2816
  }));
2704
- return { isGitRepo: true, branch, baseRef: base.ref, files: [...tracked, ...untracked] };
2817
+ const files = [...tracked, ...untracked];
2818
+ return {
2819
+ isGitRepo: true,
2820
+ branch,
2821
+ baseRef: base.ref,
2822
+ files: baseline ? await dropPreexisting(cwd, files, baseline) : files
2823
+ };
2705
2824
  }
2706
- async function enrichSessionWithGitMetadata(session) {
2825
+ async function enrichSessionWithGitMetadata(session, baseline) {
2707
2826
  const [branch, repositoryFullName, status, lastActivityAt] = await Promise.all([
2708
2827
  getCurrentBranch(session.cwd),
2709
2828
  getRepositoryFullName(session.cwd),
2710
- getWorkspaceStatus(session.cwd),
2829
+ getWorkspaceStatus(session.cwd, baseline),
2711
2830
  getPaneActivity(session.tmuxSessionName)
2712
2831
  ]);
2713
2832
  const changedFileCount = status.isGitRepo ? new Set(status.files.map((f) => f.path)).size : null;
@@ -2722,7 +2841,7 @@ async function enrichSessionWithGitMetadata(session) {
2722
2841
 
2723
2842
  // src/sessions/git/diff.ts
2724
2843
  async function withRenameSource(cwd, baseSha, path) {
2725
- const nameStatus = await runGitDiff(["--name-status", "-z", "-M", baseSha], cwd);
2844
+ const nameStatus = await runGitDiff(["--relative", "--name-status", "-z", "-M", baseSha], cwd);
2726
2845
  const previousPath = parseNameStatus(nameStatus).find((c) => c.path === path)?.previousPath;
2727
2846
  return previousPath === void 0 ? [path] : [previousPath, path];
2728
2847
  }
@@ -2730,15 +2849,18 @@ async function isUntracked(cwd, path) {
2730
2849
  const out = await runGit(["ls-files", "--others", "--exclude-standard", "--", path], cwd);
2731
2850
  return out.trim() !== "";
2732
2851
  }
2733
- async function getWorkspaceDiff(cwd, path, staged) {
2734
- const base = await resolveComparisonBase(cwd);
2852
+ async function getWorkspaceDiff(cwd, path, staged, baseline) {
2853
+ const base = await resolveBaseFor(cwd, baseline);
2735
2854
  const pathspec = await withRenameSource(cwd, base.sha, path);
2736
- const numstat = await runGitDiff(["--numstat", "-M", base.sha, "--", ...pathspec], cwd);
2855
+ const numstat = await runGitDiff(
2856
+ ["--relative", "--numstat", "-M", base.sha, "--", ...pathspec],
2857
+ cwd
2858
+ );
2737
2859
  const binary = /^(-\t-\t)/.test(numstat.trim());
2738
2860
  if (binary) {
2739
2861
  return { path, staged, patch: null, binary: true };
2740
2862
  }
2741
- let patch = await runGitDiff(["-M", base.sha, "--", ...pathspec], cwd);
2863
+ let patch = await runGitDiff(["--relative", "-M", base.sha, "--", ...pathspec], cwd);
2742
2864
  if (patch.trim() === "" && await isUntracked(cwd, path)) {
2743
2865
  patch = await runGitDiffAllowingExitCodes(["--no-index", "--", "/dev/null", path], cwd, [
2744
2866
  1
@@ -2935,7 +3057,7 @@ async function removeWorktree(sourceCwd, worktreePath) {
2935
3057
  // src/sessions/tmux/create.ts
2936
3058
  import { access } from "fs/promises";
2937
3059
  import { homedir as homedir5 } from "os";
2938
- import { join as join5 } from "path";
3060
+ import { join as join6 } from "path";
2939
3061
  var InvalidCwdError = class extends Error {
2940
3062
  constructor(cwd) {
2941
3063
  super(`working directory does not exist: ${cwd}`);
@@ -2943,7 +3065,7 @@ var InvalidCwdError = class extends Error {
2943
3065
  }
2944
3066
  };
2945
3067
  function worktreePathFor(sessionId) {
2946
- return join5(homedir5(), ".bondsmith", "worktrees", sessionId);
3068
+ return join6(homedir5(), ".bondsmith", "worktrees", sessionId);
2947
3069
  }
2948
3070
  async function createTmuxSession(input) {
2949
3071
  const sourceCwd = expandHome(input.cwd);
@@ -2962,6 +3084,7 @@ async function createTmuxSession(input) {
2962
3084
  }
2963
3085
  const tmuxSessionName = tmuxSessionNameFor(input.id);
2964
3086
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3087
+ const baseline = await captureBaseline(cwd, { freshCheckout: worktreeOf !== null });
2965
3088
  await runTmux(["new-session", "-d", "-s", tmuxSessionName, "-c", cwd, input.command], {
2966
3089
  env: filterSpawnEnv(process.env)
2967
3090
  });
@@ -2973,6 +3096,15 @@ async function createTmuxSession(input) {
2973
3096
  if (worktreeOf) {
2974
3097
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-worktree-of", worktreeOf]);
2975
3098
  }
3099
+ if (baseline) {
3100
+ await runTmux([
3101
+ "set-option",
3102
+ "-t",
3103
+ tmuxSessionName,
3104
+ "@bondsmith-baseline",
3105
+ encodeBaseline(baseline)
3106
+ ]);
3107
+ }
2976
3108
  const session = {
2977
3109
  id: input.id,
2978
3110
  title: input.title,
@@ -2987,7 +3119,7 @@ async function createTmuxSession(input) {
2987
3119
  changedFileCount: null,
2988
3120
  worktreeOf
2989
3121
  };
2990
- return enrichSessionWithGitMetadata(session);
3122
+ return { session: await enrichSessionWithGitMetadata(session, baseline), baseline };
2991
3123
  }
2992
3124
  async function stopTmuxSession(tmuxSessionName) {
2993
3125
  await runTmux(["kill-session", "-t", tmuxSessionName]);
@@ -2995,6 +3127,7 @@ async function stopTmuxSession(tmuxSessionName) {
2995
3127
 
2996
3128
  // src/sessions/tmux/parse.ts
2997
3129
  var FIELD_COUNT = 7;
3130
+ var BASELINE_FIELD_INDEX = 7;
2998
3131
  function parseTmuxSessions(output) {
2999
3132
  const sessions = [];
3000
3133
  for (const line of output.split("\n")) {
@@ -3024,6 +3157,19 @@ function parseTmuxSessions(output) {
3024
3157
  }
3025
3158
  return sessions;
3026
3159
  }
3160
+ function parseSessionBaselines(output) {
3161
+ const baselines = /* @__PURE__ */ new Map();
3162
+ for (const line of output.split("\n")) {
3163
+ const fields = line.split(" ");
3164
+ const sessionName = fields[0];
3165
+ const id = fields[1];
3166
+ const encoded = fields[BASELINE_FIELD_INDEX];
3167
+ if (!sessionName || !isBondsmithTmuxSessionName(sessionName) || !id || !encoded) continue;
3168
+ const baseline = decodeBaseline(encoded);
3169
+ if (baseline) baselines.set(id, baseline);
3170
+ }
3171
+ return baselines;
3172
+ }
3027
3173
 
3028
3174
  // src/sessions/tmux/discover.ts
3029
3175
  var LIST_FORMAT = [
@@ -3033,15 +3179,21 @@ var LIST_FORMAT = [
3033
3179
  "#{@bondsmith-command}",
3034
3180
  "#{@bondsmith-cwd}",
3035
3181
  "#{@bondsmith-started-at}",
3036
- "#{@bondsmith-worktree-of}"
3182
+ "#{@bondsmith-worktree-of}",
3183
+ "#{@bondsmith-baseline}"
3037
3184
  ].join(" ");
3038
3185
  async function discoverSessions() {
3039
3186
  try {
3040
3187
  const output = await listTmuxSessions(LIST_FORMAT);
3041
- const sessions = parseTmuxSessions(output);
3042
- return Promise.all(sessions.map((session) => enrichSessionWithGitMetadata(session)));
3188
+ const baselines = parseSessionBaselines(output);
3189
+ const sessions = await Promise.all(
3190
+ parseTmuxSessions(output).map(
3191
+ (session) => enrichSessionWithGitMetadata(session, baselines.get(session.id))
3192
+ )
3193
+ );
3194
+ return { sessions, baselines };
3043
3195
  } catch (error2) {
3044
- if (error2 instanceof TmuxNoServerRunningError) return [];
3196
+ if (error2 instanceof TmuxNoServerRunningError) return { sessions: [], baselines: /* @__PURE__ */ new Map() };
3045
3197
  if (error2 instanceof TmuxNotFoundError) {
3046
3198
  console.error(`
3047
3199
  ${error2.message}
@@ -3055,13 +3207,19 @@ ${error2.message}
3055
3207
  // src/sessions/session-manager.ts
3056
3208
  var SessionManager = class {
3057
3209
  sessions = /* @__PURE__ */ new Map();
3210
+ // What each session's folder looked like when it started, so its file list shows only its own changes.
3211
+ baselines = /* @__PURE__ */ new Map();
3058
3212
  listeners = /* @__PURE__ */ new Set();
3059
3213
  async initialize() {
3060
3214
  const discovered = await discoverSessions();
3061
3215
  this.sessions.clear();
3062
- for (const session of discovered) {
3216
+ this.baselines.clear();
3217
+ for (const session of discovered.sessions) {
3063
3218
  this.sessions.set(session.id, session);
3064
3219
  }
3220
+ for (const [id, baseline] of discovered.baselines) {
3221
+ this.baselines.set(id, baseline);
3222
+ }
3065
3223
  }
3066
3224
  list() {
3067
3225
  return Array.from(this.sessions.values());
@@ -3069,6 +3227,10 @@ var SessionManager = class {
3069
3227
  get(sessionId) {
3070
3228
  return this.sessions.get(sessionId);
3071
3229
  }
3230
+ /** Undefined for a session started before baselines existed, which compares against the default branch. */
3231
+ getBaseline(sessionId) {
3232
+ return this.baselines.get(sessionId);
3233
+ }
3072
3234
  onSessionUpdated(listener) {
3073
3235
  this.listeners.add(listener);
3074
3236
  return () => this.listeners.delete(listener);
@@ -3078,8 +3240,9 @@ var SessionManager = class {
3078
3240
  }
3079
3241
  async create(input) {
3080
3242
  const id = uuidv4();
3081
- const session = await createTmuxSession({ id, ...input });
3243
+ const { session, baseline } = await createTmuxSession({ id, ...input });
3082
3244
  this.sessions.set(id, session);
3245
+ if (baseline) this.baselines.set(id, baseline);
3083
3246
  return session;
3084
3247
  }
3085
3248
  async stop(sessionId) {
@@ -3139,7 +3302,10 @@ ${error2.message}
3139
3302
  return;
3140
3303
  }
3141
3304
  try {
3142
- const updated = await enrichSessionWithGitMetadata(session);
3305
+ const updated = await enrichSessionWithGitMetadata(
3306
+ session,
3307
+ this.baselines.get(session.id)
3308
+ );
3143
3309
  if (!gitSnapshotEquals(session, updated)) {
3144
3310
  this.sessions.set(session.id, updated);
3145
3311
  this.emitUpdated(updated);
@@ -3156,6 +3322,7 @@ ${error2.message}
3156
3322
  removeAsExited(session) {
3157
3323
  const updated = { ...session, status: "exited" };
3158
3324
  this.sessions.delete(session.id);
3325
+ this.baselines.delete(session.id);
3159
3326
  this.emitUpdated(updated);
3160
3327
  return updated;
3161
3328
  }
@@ -3316,10 +3483,10 @@ if (isVersionRequest(process.argv.slice(2))) {
3316
3483
  process.exit(0);
3317
3484
  }
3318
3485
  function loadRepoRootEnv() {
3319
- const repoRoot = join6(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
3320
- if (!existsSync(join6(repoRoot, "pnpm-workspace.yaml"))) return;
3321
- const envLocal = join6(repoRoot, ".env.local");
3322
- const envDefault = join6(repoRoot, ".env");
3486
+ const repoRoot = join7(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
3487
+ if (!existsSync(join7(repoRoot, "pnpm-workspace.yaml"))) return;
3488
+ const envLocal = join7(repoRoot, ".env.local");
3489
+ const envDefault = join7(repoRoot, ".env");
3323
3490
  loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
3324
3491
  }
3325
3492
  loadRepoRootEnv();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bondsmith/bondsmithd",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",