@bondsmith/bondsmithd 0.1.14 → 0.1.15

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 +11 -6
  2. package/dist/index.js +94 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -82,20 +82,20 @@ that pane belongs to:
82
82
  tmux set-option -t "$TMUX_PANE" -q @bondsmith-agent-state "waiting:$(date +%s)"
83
83
  ```
84
84
 
85
- The value is `<state>:<epoch seconds>`, where state is `working`, `waiting` (needs the user) or `done`.
85
+ The value is `<state>:<epoch seconds>`, where state is `working`, `waiting` (needs the user) or `done` (finished a turn, shown until the user attaches, types or resizes after it).
86
86
  The daemon reads it on its 5-second tick and shows it on the session. Anything else is ignored, as is
87
87
  a report dated in the future. A `waiting` or `done` report is dropped once the pane writes output more
88
88
  than 2 seconds after it, since that means the agent moved on. A `working` report is dropped once the
89
89
  pane has been silent for 2 minutes, since an interrupted or crashed agent never reports again.
90
90
 
91
91
  Claude Code sessions that Bondsmith starts as plain `claude` report this on their own (see the end of the
92
- plan section below): `working` when a prompt is sent, `waiting` when it asks for permission, finishes a
93
- turn or fails with an API error. Nothing needs installing for those.
92
+ plan section below): `working` when a prompt is sent, `waiting` when it asks for permission or fails with
93
+ an API error, `done` when it finishes a turn. Nothing needs installing for those.
94
94
 
95
95
  For an agent the daemon does not set up (a wrapper, `npx claude`, another agent), note that every session
96
96
  Bondsmith starts has `BONDSMITH_SESSION` set to its id, so a hook installed once for all your projects can
97
97
  stay silent everywhere else. This entry in `~/.claude/settings.json` reports a Claude Code session as
98
- waiting the moment it asks for permission or finishes a turn:
98
+ waiting the moment it asks for permission, and as done when it finishes a turn:
99
99
 
100
100
  ```json
101
101
  {
@@ -116,7 +116,7 @@ waiting the moment it asks for permission or finishes a turn:
116
116
  "hooks": [
117
117
  {
118
118
  "type": "command",
119
- "command": "[ -n \"$BONDSMITH_SESSION\" ] && tmux set-option -t \"$TMUX_PANE\" -q @bondsmith-agent-state \"waiting:$(date +%s)\"; exit 0"
119
+ "command": "[ -n \"$BONDSMITH_SESSION\" ] && tmux set-option -t \"$TMUX_PANE\" -q @bondsmith-agent-state \"done:$(date +%s)\"; exit 0"
120
120
  }
121
121
  ]
122
122
  }
@@ -127,7 +127,7 @@ waiting the moment it asks for permission or finishes a turn:
127
127
 
128
128
  This is optional, and nothing edits your settings for you. The command does nothing outside a Bondsmith
129
129
  session, and sessions started before the daemon set the variable are skipped by it. It reports only
130
- `waiting`. A `working` report is dropped after the pane has been silent for 2 minutes, so one that an
130
+ `waiting` and `done`. A `working` report is dropped after the pane has been silent for 2 minutes, so one that an
131
131
  interrupted Claude Code never clears (it does not run `Stop` then, and has no hook for Escape) expires on
132
132
  its own.
133
133
 
@@ -157,6 +157,11 @@ that only answers the agent (`yes`, `continue`, `ok`, `thanks`, and a few like t
157
157
  characters leaves the earlier headline in place; a built-in slash command such as `/help` sends no prompt to
158
158
  the hook, so it does too. The prompt is stored as typed, so a secret in a prompt is in the headline.
159
159
 
160
+ A sixth hook watches Bash calls for `gh pr create`. When one succeeds and prints a pull request URL, the URL is
161
+ appended to the tmux option `@bondsmith-prs` (space separated, each once, the newest 20 kept), and the daemon
162
+ reports it on the session as `pullRequestUrls`. It matches `gh pr create` only as a command of its own, so
163
+ `echo "gh pr create"` or `gh pr view` record nothing, and a failed command never reaches it.
164
+
160
165
  The daemon leaves the command alone if it already has `--settings`, if the installed `claude` does not list
161
166
  `--settings` in `claude --help` (asked once per daemon run; if `claude` cannot be run at all, that is logged
162
167
  and asked again after a minute), or if `BONDSMITH_AGENT_HOOKS` is set to `off`, `0`, `false` or `no` where
package/dist/index.js CHANGED
@@ -11,13 +11,14 @@ import { fileURLToPath } from "url";
11
11
  var SESSION_STATUSES = ["running", "exited"];
12
12
  var AGENT_STATES = ["working", "waiting", "done"];
13
13
  var MAX_SKIPPED_FILES_REPORTED = 20;
14
+ var MAX_SESSION_PULL_REQUESTS = 20;
14
15
 
15
16
  // ../../packages/local-agent-protocol/src/messages.ts
16
17
  var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
17
18
  var INVALID_MESSAGE_ERROR = "invalid message";
18
19
 
19
20
  // ../../packages/local-agent-protocol/src/protocol-version.ts
20
- var PROTOCOL_VERSION = 17;
21
+ var PROTOCOL_VERSION = 19;
21
22
 
22
23
  // ../../packages/local-agent-protocol/src/branch-name.ts
23
24
  var MAX_BRANCH_NAME_LENGTH = 250;
@@ -206,7 +207,8 @@ var localSessionSchema = z3.object({
206
207
  agentState: z3.enum(AGENT_STATES).nullable().optional().catch(void 0),
207
208
  worktreeSetupFailed: z3.boolean().optional().catch(void 0),
208
209
  worktreeSetupSkipped: z3.array(z3.string().max(300)).max(MAX_SKIPPED_FILES_REPORTED).optional().catch(void 0),
209
- headline: z3.string().nullable().optional().catch(void 0)
210
+ headline: z3.string().nullable().optional().catch(void 0),
211
+ pullRequestUrls: z3.array(z3.string().max(300)).max(MAX_SESSION_PULL_REQUESTS).optional().catch(void 0)
210
212
  });
211
213
  var workspaceFileEntrySchema = z3.object({
212
214
  path: z3.string(),
@@ -317,6 +319,11 @@ var browserMessageSchema = z3.discriminatedUnion("type", [
317
319
  sessionId: z3.string(),
318
320
  ...terminalSizeSchema.shape
319
321
  }),
322
+ z3.object({
323
+ type: z3.literal("session.setMouseMode"),
324
+ sessionId: z3.string(),
325
+ enabled: z3.boolean()
326
+ }),
320
327
  z3.object({ type: z3.literal("session.stop"), sessionId: z3.string() }),
321
328
  z3.object({
322
329
  type: z3.literal("workspace.getStatus"),
@@ -527,6 +534,7 @@ var SIGNABLE_ACTIONS = [
527
534
  "session.input",
528
535
  "session.insertText",
529
536
  "session.resize",
537
+ "session.setMouseMode",
530
538
  "session.stop",
531
539
  "session.list",
532
540
  "workspace.getStatus",
@@ -1457,6 +1465,12 @@ async function runTmux(args, options) {
1457
1465
  async function sendKeysLiteral(tmuxSessionName, text) {
1458
1466
  await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text]);
1459
1467
  }
1468
+ async function setMouseMode(tmuxSessionName, enabled) {
1469
+ await runTmux(["set-option", "-t", tmuxSessionName, "mouse", enabled ? "on" : "off"]);
1470
+ }
1471
+ async function cancelCopyMode(tmuxSessionName) {
1472
+ await runTmux(["copy-mode", "-q", "-t", tmuxSessionName]);
1473
+ }
1460
1474
  async function capturePane(tmuxSessionName) {
1461
1475
  return runTmux(["capture-pane", "-t", tmuxSessionName, "-p", "-e"]);
1462
1476
  }
@@ -2268,7 +2282,7 @@ function listInstalledAgents(env = process.env) {
2268
2282
  }
2269
2283
 
2270
2284
  // src/version.ts
2271
- var DAEMON_VERSION = true ? "0.1.14" : "dev";
2285
+ var DAEMON_VERSION = true ? "0.1.15" : "dev";
2272
2286
  var VERSION_FLAGS = ["--version", "-v"];
2273
2287
  function versionLine() {
2274
2288
  return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
@@ -2445,6 +2459,7 @@ function verifyRejectionMessage(reason) {
2445
2459
  var REVOKED_REJECTION_MESSAGE = "connection has not completed its handshake";
2446
2460
  var BRACKETED_PASTE_START = "\x1B[200~";
2447
2461
  var BRACKETED_PASTE_END = "\x1B[201~";
2462
+ var WHEEL_ONLY_INPUT = /^(?:\x1b\[<6[45];\d+;\d+M)+$/;
2448
2463
  function releaseSession(conn, ptyAttachments, sessionId) {
2449
2464
  const listener = conn.getOutputListener(sessionId);
2450
2465
  if (listener) ptyAttachments.release(sessionId, listener);
@@ -2458,6 +2473,7 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
2458
2473
  let admissionChain = Promise.resolve();
2459
2474
  const conn = {
2460
2475
  subscriptions: /* @__PURE__ */ new Set(),
2476
+ mouseModeSessions: /* @__PURE__ */ new Map(),
2461
2477
  send,
2462
2478
  bufferedBytes,
2463
2479
  isClosed: () => closed,
@@ -2496,6 +2512,11 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
2496
2512
  for (const sessionId of [...conn.subscriptions]) {
2497
2513
  releaseSession(conn, ptyAttachments, sessionId);
2498
2514
  }
2515
+ for (const tmuxSessionName of conn.mouseModeSessions.values()) {
2516
+ setMouseMode(tmuxSessionName, false).catch(() => {
2517
+ });
2518
+ }
2519
+ conn.mouseModeSessions.clear();
2499
2520
  }
2500
2521
  return { conn, releaseAllSubscriptions };
2501
2522
  }
@@ -2729,6 +2750,11 @@ async function dispatch(conn, message, {
2729
2750
  return;
2730
2751
  }
2731
2752
  sessionManager.acknowledge(message.sessionId);
2753
+ const tmuxSessionName = conn.mouseModeSessions.get(message.sessionId);
2754
+ if (tmuxSessionName && !WHEEL_ONLY_INPUT.test(message.data)) {
2755
+ await cancelCopyMode(tmuxSessionName).catch(() => {
2756
+ });
2757
+ }
2732
2758
  ptyAttachments.write(message.sessionId, message.data);
2733
2759
  return;
2734
2760
  }
@@ -2759,6 +2785,20 @@ async function dispatch(conn, message, {
2759
2785
  ptyAttachments.resize(message.sessionId, message.cols, message.rows);
2760
2786
  return;
2761
2787
  }
2788
+ case "session.setMouseMode": {
2789
+ const session = sessionManager.get(message.sessionId);
2790
+ if (!session) return;
2791
+ if (message.enabled) conn.mouseModeSessions.set(session.id, session.tmuxSessionName);
2792
+ else conn.mouseModeSessions.delete(session.id);
2793
+ try {
2794
+ await setMouseMode(session.tmuxSessionName, message.enabled);
2795
+ if (message.enabled && conn.isClosed()) await setMouseMode(session.tmuxSessionName, false);
2796
+ } catch (err) {
2797
+ if (message.enabled) conn.mouseModeSessions.delete(session.id);
2798
+ console.warn(`bondsmithd: ${errorMessageOf(err, "failed to set mouse mode")}`);
2799
+ }
2800
+ return;
2801
+ }
2762
2802
  case "session.stop": {
2763
2803
  try {
2764
2804
  if (!await sessionManager.stop(message.sessionId)) {
@@ -3990,8 +4030,10 @@ function decodeSkippedFiles(raw) {
3990
4030
  // src/sessions/tmux/agent-state.ts
3991
4031
  var AGENT_STATE_OPTION = "@bondsmith-agent-state";
3992
4032
  var HEADLINE_OPTION = "@bondsmith-headline";
4033
+ var PULL_REQUESTS_OPTION = "@bondsmith-prs";
4034
+ var PULL_REQUEST_URL = /https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+/g;
3993
4035
  var MAX_HEADLINE_CHARS = 200;
3994
- var LIVE_SESSION_REPORTS_FORMAT = `#{session_name} #{${AGENT_STATE_OPTION}} #{window_bell_flag} #{${SETUP_FAILED_OPTION}} #{${SETUP_SKIPPED_OPTION}} #{${HEADLINE_OPTION}}`;
4036
+ var LIVE_SESSION_REPORTS_FORMAT = `#{session_name} #{${AGENT_STATE_OPTION}} #{window_bell_flag} #{${SETUP_FAILED_OPTION}} #{${SETUP_SKIPPED_OPTION}} #{${PULL_REQUESTS_OPTION}} #{${HEADLINE_OPTION}}`;
3995
4037
  var AGENT_STATE_OUTPUT_GRACE_MS = 2e3;
3996
4038
  var FUTURE_REPORT_ALLOWANCE_MS = 2e3;
3997
4039
  var WORKING_SILENCE_MS = 12e4;
@@ -4003,6 +4045,10 @@ function cleanHeadline(raw) {
4003
4045
  if (!line) return null;
4004
4046
  return line.length > MAX_HEADLINE_CHARS ? `${line.slice(0, MAX_HEADLINE_CHARS - 1)}\u2026` : line;
4005
4047
  }
4048
+ function parsePullRequestUrls(raw) {
4049
+ const urls = [...new Set(raw.match(PULL_REQUEST_URL) ?? [])];
4050
+ return urls.slice(-MAX_SESSION_PULL_REQUESTS);
4051
+ }
4006
4052
  function isAgentState(value) {
4007
4053
  return AGENT_STATES.includes(value);
4008
4054
  }
@@ -4015,6 +4061,7 @@ function parseLiveSessionReports(output) {
4015
4061
  bell = "",
4016
4062
  setupFailed = "",
4017
4063
  setupSkipped = "",
4064
+ pullRequests = "",
4018
4065
  ...headline
4019
4066
  ] = line.split(" ");
4020
4067
  if (sessionName) {
@@ -4023,17 +4070,23 @@ function parseLiveSessionReports(output) {
4023
4070
  bell: bell === "1",
4024
4071
  setupFailed: setupFailed === "1",
4025
4072
  setupSkipped: decodeSkippedFiles(setupSkipped),
4073
+ pullRequestUrls: parsePullRequestUrls(pullRequests),
4026
4074
  headline: cleanHeadline(headline.join(" "))
4027
4075
  });
4028
4076
  }
4029
4077
  }
4030
4078
  return reports;
4031
4079
  }
4032
- function resolveAgentState(raw, lastActivityAt, nowMs = Date.now()) {
4080
+ function parseReport(raw) {
4033
4081
  if (!raw) return null;
4034
4082
  const [state, epoch] = raw.split(":");
4035
4083
  if (!state || !isAgentState(state) || !epoch || !/^\d+$/.test(epoch)) return null;
4036
- const reportedAtMs = Number(epoch) * 1e3;
4084
+ return { state, reportedAtMs: Number(epoch) * 1e3 };
4085
+ }
4086
+ function resolveAgentState(raw, lastActivityAt, nowMs = Date.now()) {
4087
+ const parsed = parseReport(raw);
4088
+ if (!parsed) return null;
4089
+ const { state, reportedAtMs } = parsed;
4037
4090
  if (reportedAtMs > nowMs + FUTURE_REPORT_ALLOWANCE_MS) return null;
4038
4091
  if (SETTLED_STATES.has(state) && lastActivityAt) {
4039
4092
  if (Date.parse(lastActivityAt) > reportedAtMs + AGENT_STATE_OUTPUT_GRACE_MS) return null;
@@ -4053,9 +4106,16 @@ function hasSettled(lastActivityAt, acknowledgedAtMs, nowMs = Date.now()) {
4053
4106
  }
4054
4107
  return nowMs - lastActivityMs >= SETTLE_QUIET_MS;
4055
4108
  }
4109
+ var REPORT_ROUNDING_MS = 1e3;
4110
+ function isDoneAcknowledged(raw, acknowledgedAtMs) {
4111
+ const reportedAtMs = parseReport(raw)?.reportedAtMs;
4112
+ if (acknowledgedAtMs === null || reportedAtMs === void 0) return false;
4113
+ return acknowledgedAtMs >= reportedAtMs + REPORT_ROUNDING_MS;
4114
+ }
4056
4115
  function resolveSessionAttention(report, lastActivityAt, { nowMs, acknowledgedAtMs = null } = {}) {
4057
4116
  if (report?.bell) return "waiting";
4058
4117
  const reported = resolveAgentState(report?.agentState, lastActivityAt, nowMs);
4118
+ if (reported === "done" && isDoneAcknowledged(report?.agentState, acknowledgedAtMs)) return null;
4059
4119
  if (reported) return reported;
4060
4120
  return hasSettled(lastActivityAt, acknowledgedAtMs, nowMs) ? "waiting" : null;
4061
4121
  }
@@ -4106,6 +4166,21 @@ function headlineHookScript() {
4106
4166
  `}catch{}})`
4107
4167
  ].join("");
4108
4168
  }
4169
+ var PR_CREATE_COMMAND = /(?:^|[;&|\n(])\s*(?:\w+=\S*\s+)*gh\s+pr\s+create\b/;
4170
+ function pullRequestHookScript() {
4171
+ return [
4172
+ `let d="";`,
4173
+ `process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
4174
+ `const j=JSON.parse(d),c=j.tool_input?.command,o=j.tool_response?.stdout;`,
4175
+ `if(typeof c!=="string"||typeof o!=="string"||!${PR_CREATE_COMMAND.toString()}.test(c))return;`,
4176
+ `const m=o.match(${PULL_REQUEST_URL.toString()});if(!m)return;const u=m[m.length-1];`,
4177
+ `const cp=require("child_process"),p=process.env.TMUX_PANE;`,
4178
+ `const cur=cp.execFileSync("tmux",["show-options","-qv","-t",p,"${PULL_REQUESTS_OPTION}"],{encoding:"utf8"}).split(/\\s+/).filter(Boolean);`,
4179
+ `if(cur.includes(u))return;`,
4180
+ `cp.execFileSync("tmux",["set-option","-t",p,"-q","${PULL_REQUESTS_OPTION}",cur.concat(u).slice(-${MAX_SESSION_PULL_REQUESTS}).join(" ")])`,
4181
+ `}catch{}})`
4182
+ ].join("");
4183
+ }
4109
4184
 
4110
4185
  // src/sessions/agents/claude-settings.ts
4111
4186
  function shellQuote3(value) {
@@ -4120,12 +4195,17 @@ function planHookCommand(nodePath = process.execPath, plansDir = HOOK_PLANS_DIR)
4120
4195
  function headlineHookCommand(nodePath = process.execPath) {
4121
4196
  return nodeHookCommand(headlineHookScript(), nodePath);
4122
4197
  }
4198
+ function pullRequestHookCommand(nodePath = process.execPath) {
4199
+ const run = `${shellQuote3(nodePath)} -e ${shellQuote3(pullRequestHookScript())}`;
4200
+ return `[ -n "$${SESSION_ENV_VAR}" ] && { input=$(cat); case "$input" in *"gh pr create"*) printf '%s' "$input" | ${run};; esac; }; exit 0`;
4201
+ }
4123
4202
  function stateHookCommand(state) {
4124
4203
  return `[ -n "$${SESSION_ENV_VAR}" ] && tmux set-option -t "$TMUX_PANE" -q ${AGENT_STATE_OPTION} "${state}:$(date +%s)"; exit 0`;
4125
4204
  }
4126
4205
  var STATE_HOOK_TIMEOUT_SECONDS = 5;
4127
4206
  var PLAN_HOOK_TIMEOUT_SECONDS = 10;
4128
4207
  var HEADLINE_HOOK_TIMEOUT_SECONDS = 5;
4208
+ var PULL_REQUEST_HOOK_TIMEOUT_SECONDS = 5;
4129
4209
  function claudeSettings() {
4130
4210
  const run = (command, timeout) => ({ type: "command", command, timeout });
4131
4211
  const report = (state) => run(stateHookCommand(state), STATE_HOOK_TIMEOUT_SECONDS);
@@ -4135,16 +4215,20 @@ function claudeSettings() {
4135
4215
  {
4136
4216
  matcher: "ExitPlanMode",
4137
4217
  hooks: [run(planHookCommand(), PLAN_HOOK_TIMEOUT_SECONDS)]
4218
+ },
4219
+ {
4220
+ matcher: "Bash",
4221
+ hooks: [run(pullRequestHookCommand(), PULL_REQUEST_HOOK_TIMEOUT_SECONDS)]
4138
4222
  }
4139
4223
  ],
4140
- // Working from the moment a prompt is sent, which is also the task the session is on; waiting whenever the user is next to act.
4224
+ // Working from the moment a prompt is sent, which is also the task the session is on; waiting when it needs the user; done when a turn ends.
4141
4225
  UserPromptSubmit: [
4142
4226
  {
4143
4227
  hooks: [report("working"), run(headlineHookCommand(), HEADLINE_HOOK_TIMEOUT_SECONDS)]
4144
4228
  }
4145
4229
  ],
4146
4230
  PermissionRequest: [{ matcher: "*", hooks: [report("waiting")] }],
4147
- Stop: [{ hooks: [report("waiting")] }],
4231
+ Stop: [{ hooks: [report("done")] }],
4148
4232
  StopFailure: [{ hooks: [report("waiting")] }]
4149
4233
  }
4150
4234
  };
@@ -4661,6 +4745,7 @@ ${error2.message}
4661
4745
  acknowledgedAtMs: this.acknowledgements.get(session.id) ?? null
4662
4746
  }),
4663
4747
  headline: report?.headline ?? null,
4748
+ ...report?.pullRequestUrls?.length && { pullRequestUrls: report.pullRequestUrls },
4664
4749
  ...report?.setupFailed && { worktreeSetupFailed: true },
4665
4750
  ...report?.setupSkipped?.length && { worktreeSetupSkipped: report.setupSkipped }
4666
4751
  };
@@ -4687,7 +4772,7 @@ ${error2.message}
4687
4772
  }
4688
4773
  };
4689
4774
  function snapshotEquals(a, b) {
4690
- return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt && (a.agentState ?? null) === (b.agentState ?? null) && (a.headline ?? null) === (b.headline ?? null) && (a.worktreeSetupFailed ?? false) === (b.worktreeSetupFailed ?? false) && JSON.stringify(a.worktreeSetupSkipped ?? []) === JSON.stringify(b.worktreeSetupSkipped ?? []);
4775
+ return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt && (a.agentState ?? null) === (b.agentState ?? null) && (a.headline ?? null) === (b.headline ?? null) && JSON.stringify(a.pullRequestUrls ?? []) === JSON.stringify(b.pullRequestUrls ?? []) && (a.worktreeSetupFailed ?? false) === (b.worktreeSetupFailed ?? false) && JSON.stringify(a.worktreeSetupSkipped ?? []) === JSON.stringify(b.worktreeSetupSkipped ?? []);
4691
4776
  }
4692
4777
 
4693
4778
  // src/ws/server.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bondsmith/bondsmithd",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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",