@bondsmith/bondsmithd 0.1.5 → 0.1.7

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 +24 -0
  2. package/dist/index.js +250 -42
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -66,6 +66,30 @@ daemon". Reload the page first; if that doesn't fix it, update the daemon as abo
66
66
  - Keeps tmux itself as the only source of truth — no database. Restarting the daemon rediscovers
67
67
  running sessions from tmux.
68
68
 
69
+ ## Agent state
70
+
71
+ An agent can tell Bondsmith what it is doing by running this from inside its session's pane, usually
72
+ from one of its hooks. `$TMUX_PANE` is set by tmux in every pane, so the command targets the session
73
+ that pane belongs to:
74
+
75
+ ```sh
76
+ tmux set-option -t "$TMUX_PANE" -q @bondsmith-agent-state "waiting:$(date +%s)"
77
+ ```
78
+
79
+ The value is `<state>:<epoch seconds>`, where state is `working`, `waiting` (needs the user) or `done`.
80
+ The daemon reads it on its 5-second tick and shows it on the session. Anything else is ignored, as is
81
+ a report dated in the future. A `waiting` or `done` report is dropped once the pane writes output more
82
+ than 2 seconds after it, since that means the agent moved on; a `working` report is not.
83
+
84
+ An agent with no hook can still get attention by ringing the terminal bell (`printf '\a'`). A bell
85
+ rung in a session nobody has looked at marks it as waiting, whatever its last report said, until tmux
86
+ clears the flag when someone attaches to the session. Agents that can ring the bell for approvals or
87
+ a finished turn work this way with nothing to install.
88
+
89
+ tmux only flags a bell when no client is attached to the session or the bell is in a window other than
90
+ the one the attached client has open. A session whose terminal is open in a browser tab, even a hidden
91
+ one, counts as being looked at, so a bell in it is not flagged; use the hook report for that case.
92
+
69
93
  ## Production connectivity (current status)
70
94
 
71
95
  `bondsmithd` connects outbound to the Bondsmith relay by default (`BONDSMITH_RELAY_ENABLED=true`,
package/dist/index.js CHANGED
@@ -4,11 +4,12 @@
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
11
11
  var SESSION_STATUSES = ["running", "exited"];
12
+ var AGENT_STATES = ["working", "waiting", "done"];
12
13
 
13
14
  // ../../packages/local-agent-protocol/src/messages.ts
14
15
  var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
@@ -76,7 +77,9 @@ var localSessionSchema = z2.object({
76
77
  repositoryFullName: z2.string().nullable(),
77
78
  lastActivityAt: z2.string().nullable(),
78
79
  changedFileCount: z2.number().int().nonnegative().nullable(),
79
- worktreeOf: z2.string().nullable()
80
+ worktreeOf: z2.string().nullable(),
81
+ // .catch(undefined): a state this version doesn't know must never drop the whole session message.
82
+ agentState: z2.enum(AGENT_STATES).nullable().optional().catch(void 0)
80
83
  });
81
84
  var workspaceFileEntrySchema = z2.object({
82
85
  path: z2.string(),
@@ -951,7 +954,7 @@ import { sign } from "crypto";
951
954
  import WebSocket from "ws";
952
955
 
953
956
  // src/version.ts
954
- var DAEMON_VERSION = true ? "0.1.5" : "dev";
957
+ var DAEMON_VERSION = true ? "0.1.7" : "dev";
955
958
  var VERSION_FLAGS = ["--version", "-v"];
956
959
  function versionLine() {
957
960
  return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
@@ -1012,9 +1015,9 @@ async function sendKeysLiteral(tmuxSessionName, text) {
1012
1015
  async function capturePane(tmuxSessionName) {
1013
1016
  return runTmux(["capture-pane", "-t", tmuxSessionName, "-p", "-e"]);
1014
1017
  }
1015
- async function getPaneActivity(tmuxSessionName) {
1018
+ async function getSessionActivity(tmuxSessionName) {
1016
1019
  try {
1017
- const out = (await runTmux(["display-message", "-p", "-t", tmuxSessionName, "#{pane_activity}"])).trim();
1020
+ const out = (await runTmux(["display-message", "-p", "-t", tmuxSessionName, "#{window_activity}"])).trim();
1018
1021
  const seconds = Number(out);
1019
1022
  if (!out || Number.isNaN(seconds)) return null;
1020
1023
  return new Date(seconds * 1e3).toISOString();
@@ -1577,7 +1580,10 @@ async function dispatch(conn, message, {
1577
1580
  const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
1578
1581
  if (!session) return;
1579
1582
  try {
1580
- const status = await getWorkspaceStatus2(session.cwd);
1583
+ const status = await getWorkspaceStatus2(
1584
+ session.cwd,
1585
+ sessionManager.getBaseline(session.id)
1586
+ );
1581
1587
  conn.send({
1582
1588
  type: "workspace.status",
1583
1589
  sessionId: message.sessionId,
@@ -1593,7 +1599,12 @@ async function dispatch(conn, message, {
1593
1599
  const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
1594
1600
  if (!session) return;
1595
1601
  try {
1596
- const diff = await getWorkspaceDiff2(session.cwd, message.path, message.staged);
1602
+ const diff = await getWorkspaceDiff2(
1603
+ session.cwd,
1604
+ message.path,
1605
+ message.staged,
1606
+ sessionManager.getBaseline(session.id)
1607
+ );
1597
1608
  conn.send({
1598
1609
  type: "workspace.diff",
1599
1610
  sessionId: message.sessionId,
@@ -2541,6 +2552,10 @@ function presentPendingAuthorizationOnTerminal(store) {
2541
2552
  };
2542
2553
  }
2543
2554
 
2555
+ // src/sessions/git/baseline.ts
2556
+ import { lstat } from "fs/promises";
2557
+ import { join as join5 } from "path";
2558
+
2544
2559
  // src/sessions/git/base.ts
2545
2560
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
2546
2561
  var DEFAULT_BRANCH_CANDIDATES = ["origin/main", "origin/master", "main", "master"];
@@ -2574,6 +2589,107 @@ async function resolveComparisonBase(cwd) {
2574
2589
  return mergeBase ? { sha: mergeBase, ref: defaultRef } : { sha: "HEAD", ref: null };
2575
2590
  }
2576
2591
 
2592
+ // src/sessions/git/baseline.ts
2593
+ var SESSION_START_REF = "session start";
2594
+ var MAX_PREEXISTING_FILES = 200;
2595
+ var DELETED = "deleted";
2596
+ async function fingerprintFile(cwd, path) {
2597
+ try {
2598
+ const stats = await lstat(join5(cwd, path));
2599
+ return `${stats.size}:${Math.trunc(stats.mtimeMs)}`;
2600
+ } catch {
2601
+ return DELETED;
2602
+ }
2603
+ }
2604
+ async function headSha(cwd) {
2605
+ try {
2606
+ return (await runGit(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], cwd)).trim() || null;
2607
+ } catch {
2608
+ return null;
2609
+ }
2610
+ }
2611
+ async function listChangedPaths(cwd, baseSha) {
2612
+ const [tracked, untracked] = await Promise.all([
2613
+ runGitDiff(["--relative", "--name-only", "-z", baseSha], cwd),
2614
+ runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
2615
+ ]);
2616
+ const paths = [...tracked.split("\0"), ...untracked.split("\0")].filter((p) => p.length > 0);
2617
+ return [...new Set(paths)];
2618
+ }
2619
+ async function captureBaseline(cwd, options) {
2620
+ try {
2621
+ await runGit(["rev-parse", "--is-inside-work-tree"], cwd);
2622
+ } catch {
2623
+ return null;
2624
+ }
2625
+ try {
2626
+ const baseSha = await headSha(cwd);
2627
+ const preexisting = /* @__PURE__ */ new Map();
2628
+ if (!options.freshCheckout) {
2629
+ const changed = await listChangedPaths(cwd, baseSha ?? EMPTY_TREE_SHA);
2630
+ for (const path of changed.slice(0, MAX_PREEXISTING_FILES)) {
2631
+ preexisting.set(path, await fingerprintFile(cwd, path));
2632
+ }
2633
+ }
2634
+ return { baseSha, preexisting };
2635
+ } catch (error2) {
2636
+ console.error("Failed to take a session baseline; comparing against the default branch", {
2637
+ cwd,
2638
+ error: error2
2639
+ });
2640
+ return null;
2641
+ }
2642
+ }
2643
+ async function commitExists(cwd, sha) {
2644
+ try {
2645
+ await runGit(["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], cwd);
2646
+ return true;
2647
+ } catch {
2648
+ return false;
2649
+ }
2650
+ }
2651
+ async function resolveBaseFor(cwd, baseline) {
2652
+ if (!baseline) return resolveComparisonBase(cwd);
2653
+ if (baseline.baseSha !== null && !await commitExists(cwd, baseline.baseSha)) {
2654
+ return resolveComparisonBase(cwd);
2655
+ }
2656
+ return { sha: baseline.baseSha ?? EMPTY_TREE_SHA, ref: SESSION_START_REF };
2657
+ }
2658
+ async function dropPreexisting(cwd, entries, baseline) {
2659
+ if (baseline.preexisting.size === 0) return entries;
2660
+ const untouched = await Promise.all(
2661
+ entries.map(async (entry) => {
2662
+ const before = baseline.preexisting.get(entry.path);
2663
+ return before !== void 0 && before === await fingerprintFile(cwd, entry.path);
2664
+ })
2665
+ );
2666
+ return entries.filter((_, index) => !untouched[index]);
2667
+ }
2668
+ function encodeBaseline(baseline) {
2669
+ const serialized = {
2670
+ v: 1,
2671
+ base: baseline.baseSha,
2672
+ files: [...baseline.preexisting]
2673
+ };
2674
+ return Buffer.from(JSON.stringify(serialized), "utf8").toString("base64url");
2675
+ }
2676
+ function isSerializedBaseline(value) {
2677
+ if (typeof value !== "object" || value === null) return false;
2678
+ const candidate = value;
2679
+ return candidate.v === 1 && (candidate.base === null || typeof candidate.base === "string") && Array.isArray(candidate.files) && candidate.files.every(
2680
+ (file) => Array.isArray(file) && file.length === 2 && typeof file[0] === "string" && typeof file[1] === "string"
2681
+ );
2682
+ }
2683
+ function decodeBaseline(encoded) {
2684
+ try {
2685
+ const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
2686
+ if (!isSerializedBaseline(parsed)) return null;
2687
+ return { baseSha: parsed.base, preexisting: new Map(parsed.files) };
2688
+ } catch {
2689
+ return null;
2690
+ }
2691
+ }
2692
+
2577
2693
  // src/sessions/git/status.ts
2578
2694
  async function getCurrentBranch(cwd) {
2579
2695
  try {
@@ -2669,15 +2785,15 @@ function parseNameStatus(output) {
2669
2785
  }
2670
2786
  return changed;
2671
2787
  }
2672
- async function getWorkspaceStatus(cwd) {
2788
+ async function getWorkspaceStatus(cwd, baseline) {
2673
2789
  const branch = await getCurrentBranch(cwd);
2674
2790
  if (branch === null && !await isGitRepo(cwd)) {
2675
2791
  return { isGitRepo: false, branch: null, files: [] };
2676
2792
  }
2677
- const base = await resolveComparisonBase(cwd);
2793
+ const base = await resolveBaseFor(cwd, baseline);
2678
2794
  const [nameStatusRaw, numstatRaw, untrackedRaw] = await Promise.all([
2679
- runGitDiff(["--name-status", "-z", "-M", base.sha], cwd),
2680
- runGitDiff(["--numstat", "-z", "-M", base.sha], cwd),
2795
+ runGitDiff(["--relative", "--name-status", "-z", "-M", base.sha], cwd),
2796
+ runGitDiff(["--relative", "--numstat", "-z", "-M", base.sha], cwd),
2681
2797
  runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
2682
2798
  ]);
2683
2799
  const numstat = parseNumstat(numstatRaw);
@@ -2701,14 +2817,20 @@ async function getWorkspaceStatus(cwd) {
2701
2817
  deletions: null,
2702
2818
  binary: false
2703
2819
  }));
2704
- return { isGitRepo: true, branch, baseRef: base.ref, files: [...tracked, ...untracked] };
2820
+ const files = [...tracked, ...untracked];
2821
+ return {
2822
+ isGitRepo: true,
2823
+ branch,
2824
+ baseRef: base.ref,
2825
+ files: baseline ? await dropPreexisting(cwd, files, baseline) : files
2826
+ };
2705
2827
  }
2706
- async function enrichSessionWithGitMetadata(session) {
2828
+ async function enrichSessionWithGitMetadata(session, baseline) {
2707
2829
  const [branch, repositoryFullName, status, lastActivityAt] = await Promise.all([
2708
2830
  getCurrentBranch(session.cwd),
2709
2831
  getRepositoryFullName(session.cwd),
2710
- getWorkspaceStatus(session.cwd),
2711
- getPaneActivity(session.tmuxSessionName)
2832
+ getWorkspaceStatus(session.cwd, baseline),
2833
+ getSessionActivity(session.tmuxSessionName)
2712
2834
  ]);
2713
2835
  const changedFileCount = status.isGitRepo ? new Set(status.files.map((f) => f.path)).size : null;
2714
2836
  return {
@@ -2722,7 +2844,7 @@ async function enrichSessionWithGitMetadata(session) {
2722
2844
 
2723
2845
  // src/sessions/git/diff.ts
2724
2846
  async function withRenameSource(cwd, baseSha, path) {
2725
- const nameStatus = await runGitDiff(["--name-status", "-z", "-M", baseSha], cwd);
2847
+ const nameStatus = await runGitDiff(["--relative", "--name-status", "-z", "-M", baseSha], cwd);
2726
2848
  const previousPath = parseNameStatus(nameStatus).find((c) => c.path === path)?.previousPath;
2727
2849
  return previousPath === void 0 ? [path] : [previousPath, path];
2728
2850
  }
@@ -2730,15 +2852,18 @@ async function isUntracked(cwd, path) {
2730
2852
  const out = await runGit(["ls-files", "--others", "--exclude-standard", "--", path], cwd);
2731
2853
  return out.trim() !== "";
2732
2854
  }
2733
- async function getWorkspaceDiff(cwd, path, staged) {
2734
- const base = await resolveComparisonBase(cwd);
2855
+ async function getWorkspaceDiff(cwd, path, staged, baseline) {
2856
+ const base = await resolveBaseFor(cwd, baseline);
2735
2857
  const pathspec = await withRenameSource(cwd, base.sha, path);
2736
- const numstat = await runGitDiff(["--numstat", "-M", base.sha, "--", ...pathspec], cwd);
2858
+ const numstat = await runGitDiff(
2859
+ ["--relative", "--numstat", "-M", base.sha, "--", ...pathspec],
2860
+ cwd
2861
+ );
2737
2862
  const binary = /^(-\t-\t)/.test(numstat.trim());
2738
2863
  if (binary) {
2739
2864
  return { path, staged, patch: null, binary: true };
2740
2865
  }
2741
- let patch = await runGitDiff(["-M", base.sha, "--", ...pathspec], cwd);
2866
+ let patch = await runGitDiff(["--relative", "-M", base.sha, "--", ...pathspec], cwd);
2742
2867
  if (patch.trim() === "" && await isUntracked(cwd, path)) {
2743
2868
  patch = await runGitDiffAllowingExitCodes(["--no-index", "--", "/dev/null", path], cwd, [
2744
2869
  1
@@ -2932,10 +3057,42 @@ async function removeWorktree(sourceCwd, worktreePath) {
2932
3057
  }
2933
3058
  }
2934
3059
 
3060
+ // src/sessions/tmux/agent-state.ts
3061
+ var LIVE_SESSION_REPORTS_FORMAT = "#{session_name} #{@bondsmith-agent-state} #{window_bell_flag}";
3062
+ var AGENT_STATE_OUTPUT_GRACE_MS = 2e3;
3063
+ var FUTURE_REPORT_ALLOWANCE_MS = 2e3;
3064
+ var SETTLED_STATES = /* @__PURE__ */ new Set(["waiting", "done"]);
3065
+ function isAgentState(value) {
3066
+ return AGENT_STATES.includes(value);
3067
+ }
3068
+ function parseLiveSessionReports(output) {
3069
+ const reports = /* @__PURE__ */ new Map();
3070
+ for (const line of output.split("\n")) {
3071
+ const [sessionName, agentState = "", bell = ""] = line.split(" ");
3072
+ if (sessionName) reports.set(sessionName, { agentState, bell: bell === "1" });
3073
+ }
3074
+ return reports;
3075
+ }
3076
+ function resolveAgentState(raw, lastActivityAt, nowMs = Date.now()) {
3077
+ if (!raw) return null;
3078
+ const [state, epoch] = raw.split(":");
3079
+ if (!state || !isAgentState(state) || !epoch || !/^\d+$/.test(epoch)) return null;
3080
+ const reportedAtMs = Number(epoch) * 1e3;
3081
+ if (reportedAtMs > nowMs + FUTURE_REPORT_ALLOWANCE_MS) return null;
3082
+ if (SETTLED_STATES.has(state) && lastActivityAt) {
3083
+ if (Date.parse(lastActivityAt) > reportedAtMs + AGENT_STATE_OUTPUT_GRACE_MS) return null;
3084
+ }
3085
+ return state;
3086
+ }
3087
+ function resolveSessionAttention(report, lastActivityAt, nowMs) {
3088
+ if (report?.bell) return "waiting";
3089
+ return resolveAgentState(report?.agentState, lastActivityAt, nowMs);
3090
+ }
3091
+
2935
3092
  // src/sessions/tmux/create.ts
2936
3093
  import { access } from "fs/promises";
2937
3094
  import { homedir as homedir5 } from "os";
2938
- import { join as join5 } from "path";
3095
+ import { join as join6 } from "path";
2939
3096
  var InvalidCwdError = class extends Error {
2940
3097
  constructor(cwd) {
2941
3098
  super(`working directory does not exist: ${cwd}`);
@@ -2943,7 +3100,7 @@ var InvalidCwdError = class extends Error {
2943
3100
  }
2944
3101
  };
2945
3102
  function worktreePathFor(sessionId) {
2946
- return join5(homedir5(), ".bondsmith", "worktrees", sessionId);
3103
+ return join6(homedir5(), ".bondsmith", "worktrees", sessionId);
2947
3104
  }
2948
3105
  async function createTmuxSession(input) {
2949
3106
  const sourceCwd = expandHome(input.cwd);
@@ -2962,6 +3119,7 @@ async function createTmuxSession(input) {
2962
3119
  }
2963
3120
  const tmuxSessionName = tmuxSessionNameFor(input.id);
2964
3121
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3122
+ const baseline = await captureBaseline(cwd, { freshCheckout: worktreeOf !== null });
2965
3123
  await runTmux(["new-session", "-d", "-s", tmuxSessionName, "-c", cwd, input.command], {
2966
3124
  env: filterSpawnEnv(process.env)
2967
3125
  });
@@ -2973,6 +3131,15 @@ async function createTmuxSession(input) {
2973
3131
  if (worktreeOf) {
2974
3132
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-worktree-of", worktreeOf]);
2975
3133
  }
3134
+ if (baseline) {
3135
+ await runTmux([
3136
+ "set-option",
3137
+ "-t",
3138
+ tmuxSessionName,
3139
+ "@bondsmith-baseline",
3140
+ encodeBaseline(baseline)
3141
+ ]);
3142
+ }
2976
3143
  const session = {
2977
3144
  id: input.id,
2978
3145
  title: input.title,
@@ -2987,7 +3154,7 @@ async function createTmuxSession(input) {
2987
3154
  changedFileCount: null,
2988
3155
  worktreeOf
2989
3156
  };
2990
- return enrichSessionWithGitMetadata(session);
3157
+ return { session: await enrichSessionWithGitMetadata(session, baseline), baseline };
2991
3158
  }
2992
3159
  async function stopTmuxSession(tmuxSessionName) {
2993
3160
  await runTmux(["kill-session", "-t", tmuxSessionName]);
@@ -2995,6 +3162,7 @@ async function stopTmuxSession(tmuxSessionName) {
2995
3162
 
2996
3163
  // src/sessions/tmux/parse.ts
2997
3164
  var FIELD_COUNT = 7;
3165
+ var BASELINE_FIELD_INDEX = 7;
2998
3166
  function parseTmuxSessions(output) {
2999
3167
  const sessions = [];
3000
3168
  for (const line of output.split("\n")) {
@@ -3024,6 +3192,19 @@ function parseTmuxSessions(output) {
3024
3192
  }
3025
3193
  return sessions;
3026
3194
  }
3195
+ function parseSessionBaselines(output) {
3196
+ const baselines = /* @__PURE__ */ new Map();
3197
+ for (const line of output.split("\n")) {
3198
+ const fields = line.split(" ");
3199
+ const sessionName = fields[0];
3200
+ const id = fields[1];
3201
+ const encoded = fields[BASELINE_FIELD_INDEX];
3202
+ if (!sessionName || !isBondsmithTmuxSessionName(sessionName) || !id || !encoded) continue;
3203
+ const baseline = decodeBaseline(encoded);
3204
+ if (baseline) baselines.set(id, baseline);
3205
+ }
3206
+ return baselines;
3207
+ }
3027
3208
 
3028
3209
  // src/sessions/tmux/discover.ts
3029
3210
  var LIST_FORMAT = [
@@ -3033,15 +3214,21 @@ var LIST_FORMAT = [
3033
3214
  "#{@bondsmith-command}",
3034
3215
  "#{@bondsmith-cwd}",
3035
3216
  "#{@bondsmith-started-at}",
3036
- "#{@bondsmith-worktree-of}"
3217
+ "#{@bondsmith-worktree-of}",
3218
+ "#{@bondsmith-baseline}"
3037
3219
  ].join(" ");
3038
3220
  async function discoverSessions() {
3039
3221
  try {
3040
3222
  const output = await listTmuxSessions(LIST_FORMAT);
3041
- const sessions = parseTmuxSessions(output);
3042
- return Promise.all(sessions.map((session) => enrichSessionWithGitMetadata(session)));
3223
+ const baselines = parseSessionBaselines(output);
3224
+ const sessions = await Promise.all(
3225
+ parseTmuxSessions(output).map(
3226
+ (session) => enrichSessionWithGitMetadata(session, baselines.get(session.id))
3227
+ )
3228
+ );
3229
+ return { sessions, baselines };
3043
3230
  } catch (error2) {
3044
- if (error2 instanceof TmuxNoServerRunningError) return [];
3231
+ if (error2 instanceof TmuxNoServerRunningError) return { sessions: [], baselines: /* @__PURE__ */ new Map() };
3045
3232
  if (error2 instanceof TmuxNotFoundError) {
3046
3233
  console.error(`
3047
3234
  ${error2.message}
@@ -3055,13 +3242,19 @@ ${error2.message}
3055
3242
  // src/sessions/session-manager.ts
3056
3243
  var SessionManager = class {
3057
3244
  sessions = /* @__PURE__ */ new Map();
3245
+ // What each session's folder looked like when it started, so its file list shows only its own changes.
3246
+ baselines = /* @__PURE__ */ new Map();
3058
3247
  listeners = /* @__PURE__ */ new Set();
3059
3248
  async initialize() {
3060
3249
  const discovered = await discoverSessions();
3061
3250
  this.sessions.clear();
3062
- for (const session of discovered) {
3251
+ this.baselines.clear();
3252
+ for (const session of discovered.sessions) {
3063
3253
  this.sessions.set(session.id, session);
3064
3254
  }
3255
+ for (const [id, baseline] of discovered.baselines) {
3256
+ this.baselines.set(id, baseline);
3257
+ }
3065
3258
  }
3066
3259
  list() {
3067
3260
  return Array.from(this.sessions.values());
@@ -3069,6 +3262,10 @@ var SessionManager = class {
3069
3262
  get(sessionId) {
3070
3263
  return this.sessions.get(sessionId);
3071
3264
  }
3265
+ /** Undefined for a session started before baselines existed, which compares against the default branch. */
3266
+ getBaseline(sessionId) {
3267
+ return this.baselines.get(sessionId);
3268
+ }
3072
3269
  onSessionUpdated(listener) {
3073
3270
  this.listeners.add(listener);
3074
3271
  return () => this.listeners.delete(listener);
@@ -3078,8 +3275,9 @@ var SessionManager = class {
3078
3275
  }
3079
3276
  async create(input) {
3080
3277
  const id = uuidv4();
3081
- const session = await createTmuxSession({ id, ...input });
3278
+ const { session, baseline } = await createTmuxSession({ id, ...input });
3082
3279
  this.sessions.set(id, session);
3280
+ if (baseline) this.baselines.set(id, baseline);
3083
3281
  return session;
3084
3282
  }
3085
3283
  async stop(sessionId) {
@@ -3114,13 +3312,12 @@ var SessionManager = class {
3114
3312
  * at create/discover — otherwise the associated-work lookup (open PR whose
3115
3313
  * head = session.branch) stays pinned to a branch the developer already
3116
3314
  * left. `session.updated` is only emitted when something actually changed,
3117
- * so an idle session doesn't broadcast every tick.
3315
+ * so an idle session doesn't broadcast every tick. The same tick re-reads each session's agent report.
3118
3316
  */
3119
3317
  async reconcile() {
3120
- let liveSessionNames = /* @__PURE__ */ new Set();
3318
+ let agentReports = /* @__PURE__ */ new Map();
3121
3319
  try {
3122
- const output = await listTmuxSessions("#{session_name}");
3123
- liveSessionNames = new Set(output.split("\n").filter((name) => name.length > 0));
3320
+ agentReports = parseLiveSessionReports(await listTmuxSessions(LIVE_SESSION_REPORTS_FORMAT));
3124
3321
  } catch (error2) {
3125
3322
  if (error2 instanceof TmuxNotFoundError) console.error(`
3126
3323
  ${error2.message}
@@ -3134,13 +3331,23 @@ ${error2.message}
3134
3331
  }
3135
3332
  await Promise.all(
3136
3333
  Array.from(this.sessions.values()).map(async (session) => {
3137
- if (!liveSessionNames.has(session.tmuxSessionName)) {
3334
+ if (!agentReports.has(session.tmuxSessionName)) {
3138
3335
  this.removeAsExited(session);
3139
3336
  return;
3140
3337
  }
3141
3338
  try {
3142
- const updated = await enrichSessionWithGitMetadata(session);
3143
- if (!gitSnapshotEquals(session, updated)) {
3339
+ const enriched = await enrichSessionWithGitMetadata(
3340
+ session,
3341
+ this.baselines.get(session.id)
3342
+ );
3343
+ const updated = {
3344
+ ...enriched,
3345
+ agentState: resolveSessionAttention(
3346
+ agentReports.get(session.tmuxSessionName),
3347
+ enriched.lastActivityAt
3348
+ )
3349
+ };
3350
+ if (!snapshotEquals(session, updated)) {
3144
3351
  this.sessions.set(session.id, updated);
3145
3352
  this.emitUpdated(updated);
3146
3353
  }
@@ -3156,12 +3363,13 @@ ${error2.message}
3156
3363
  removeAsExited(session) {
3157
3364
  const updated = { ...session, status: "exited" };
3158
3365
  this.sessions.delete(session.id);
3366
+ this.baselines.delete(session.id);
3159
3367
  this.emitUpdated(updated);
3160
3368
  return updated;
3161
3369
  }
3162
3370
  };
3163
- function gitSnapshotEquals(a, b) {
3164
- return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt;
3371
+ function snapshotEquals(a, b) {
3372
+ return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt && (a.agentState ?? null) === (b.agentState ?? null);
3165
3373
  }
3166
3374
 
3167
3375
  // src/ws/server.ts
@@ -3316,10 +3524,10 @@ if (isVersionRequest(process.argv.slice(2))) {
3316
3524
  process.exit(0);
3317
3525
  }
3318
3526
  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");
3527
+ const repoRoot = join7(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
3528
+ if (!existsSync(join7(repoRoot, "pnpm-workspace.yaml"))) return;
3529
+ const envLocal = join7(repoRoot, ".env.local");
3530
+ const envDefault = join7(repoRoot, ".env");
3323
3531
  loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
3324
3532
  }
3325
3533
  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.7",
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",