@bondsmith/bondsmithd 0.1.6 → 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 +55 -14
  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
@@ -9,6 +9,7 @@ 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.6" : "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();
@@ -2827,7 +2830,7 @@ async function enrichSessionWithGitMetadata(session, baseline) {
2827
2830
  getCurrentBranch(session.cwd),
2828
2831
  getRepositoryFullName(session.cwd),
2829
2832
  getWorkspaceStatus(session.cwd, baseline),
2830
- getPaneActivity(session.tmuxSessionName)
2833
+ getSessionActivity(session.tmuxSessionName)
2831
2834
  ]);
2832
2835
  const changedFileCount = status.isGitRepo ? new Set(status.files.map((f) => f.path)).size : null;
2833
2836
  return {
@@ -3054,6 +3057,38 @@ async function removeWorktree(sourceCwd, worktreePath) {
3054
3057
  }
3055
3058
  }
3056
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
+
3057
3092
  // src/sessions/tmux/create.ts
3058
3093
  import { access } from "fs/promises";
3059
3094
  import { homedir as homedir5 } from "os";
@@ -3277,13 +3312,12 @@ var SessionManager = class {
3277
3312
  * at create/discover — otherwise the associated-work lookup (open PR whose
3278
3313
  * head = session.branch) stays pinned to a branch the developer already
3279
3314
  * left. `session.updated` is only emitted when something actually changed,
3280
- * 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.
3281
3316
  */
3282
3317
  async reconcile() {
3283
- let liveSessionNames = /* @__PURE__ */ new Set();
3318
+ let agentReports = /* @__PURE__ */ new Map();
3284
3319
  try {
3285
- const output = await listTmuxSessions("#{session_name}");
3286
- liveSessionNames = new Set(output.split("\n").filter((name) => name.length > 0));
3320
+ agentReports = parseLiveSessionReports(await listTmuxSessions(LIVE_SESSION_REPORTS_FORMAT));
3287
3321
  } catch (error2) {
3288
3322
  if (error2 instanceof TmuxNotFoundError) console.error(`
3289
3323
  ${error2.message}
@@ -3297,16 +3331,23 @@ ${error2.message}
3297
3331
  }
3298
3332
  await Promise.all(
3299
3333
  Array.from(this.sessions.values()).map(async (session) => {
3300
- if (!liveSessionNames.has(session.tmuxSessionName)) {
3334
+ if (!agentReports.has(session.tmuxSessionName)) {
3301
3335
  this.removeAsExited(session);
3302
3336
  return;
3303
3337
  }
3304
3338
  try {
3305
- const updated = await enrichSessionWithGitMetadata(
3339
+ const enriched = await enrichSessionWithGitMetadata(
3306
3340
  session,
3307
3341
  this.baselines.get(session.id)
3308
3342
  );
3309
- if (!gitSnapshotEquals(session, updated)) {
3343
+ const updated = {
3344
+ ...enriched,
3345
+ agentState: resolveSessionAttention(
3346
+ agentReports.get(session.tmuxSessionName),
3347
+ enriched.lastActivityAt
3348
+ )
3349
+ };
3350
+ if (!snapshotEquals(session, updated)) {
3310
3351
  this.sessions.set(session.id, updated);
3311
3352
  this.emitUpdated(updated);
3312
3353
  }
@@ -3327,8 +3368,8 @@ ${error2.message}
3327
3368
  return updated;
3328
3369
  }
3329
3370
  };
3330
- function gitSnapshotEquals(a, b) {
3331
- 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);
3332
3373
  }
3333
3374
 
3334
3375
  // src/ws/server.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bondsmith/bondsmithd",
3
- "version": "0.1.6",
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",