@bondsmith/bondsmithd 0.1.13 → 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 +246 -24
  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
@@ -10,13 +10,15 @@ import { fileURLToPath } from "url";
10
10
  // ../../packages/local-agent-protocol/src/session.ts
11
11
  var SESSION_STATUSES = ["running", "exited"];
12
12
  var AGENT_STATES = ["working", "waiting", "done"];
13
+ var MAX_SKIPPED_FILES_REPORTED = 20;
14
+ var MAX_SESSION_PULL_REQUESTS = 20;
13
15
 
14
16
  // ../../packages/local-agent-protocol/src/messages.ts
15
17
  var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
16
18
  var INVALID_MESSAGE_ERROR = "invalid message";
17
19
 
18
20
  // ../../packages/local-agent-protocol/src/protocol-version.ts
19
- var PROTOCOL_VERSION = 16;
21
+ var PROTOCOL_VERSION = 19;
20
22
 
21
23
  // ../../packages/local-agent-protocol/src/branch-name.ts
22
24
  var MAX_BRANCH_NAME_LENGTH = 250;
@@ -204,7 +206,9 @@ var localSessionSchema = z3.object({
204
206
  // .catch(undefined): a state this version doesn't know must never drop the whole session message.
205
207
  agentState: z3.enum(AGENT_STATES).nullable().optional().catch(void 0),
206
208
  worktreeSetupFailed: z3.boolean().optional().catch(void 0),
207
- headline: z3.string().nullable().optional().catch(void 0)
209
+ worktreeSetupSkipped: z3.array(z3.string().max(300)).max(MAX_SKIPPED_FILES_REPORTED).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)
208
212
  });
209
213
  var workspaceFileEntrySchema = z3.object({
210
214
  path: z3.string(),
@@ -315,6 +319,11 @@ var browserMessageSchema = z3.discriminatedUnion("type", [
315
319
  sessionId: z3.string(),
316
320
  ...terminalSizeSchema.shape
317
321
  }),
322
+ z3.object({
323
+ type: z3.literal("session.setMouseMode"),
324
+ sessionId: z3.string(),
325
+ enabled: z3.boolean()
326
+ }),
318
327
  z3.object({ type: z3.literal("session.stop"), sessionId: z3.string() }),
319
328
  z3.object({
320
329
  type: z3.literal("workspace.getStatus"),
@@ -525,6 +534,7 @@ var SIGNABLE_ACTIONS = [
525
534
  "session.input",
526
535
  "session.insertText",
527
536
  "session.resize",
537
+ "session.setMouseMode",
528
538
  "session.stop",
529
539
  "session.list",
530
540
  "workspace.getStatus",
@@ -1184,7 +1194,7 @@ function expandHome(cwd) {
1184
1194
  }
1185
1195
 
1186
1196
  // src/sessions/git/client.ts
1187
- import { execFile as execFileCb } from "child_process";
1197
+ import { execFile as execFileCb, spawn as spawn2 } from "child_process";
1188
1198
  import { promisify } from "util";
1189
1199
  var execFile = promisify(execFileCb);
1190
1200
  var MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
@@ -1239,6 +1249,43 @@ function runGitDiff(args, cwd) {
1239
1249
  function runGitDiffAllowingExitCodes(args, cwd, allowedExitCodes) {
1240
1250
  return runGitAllowingExitCodes(["diff", ...SAFE_DIFF_FLAGS, ...args], cwd, allowedExitCodes);
1241
1251
  }
1252
+ var GIT_INPUT_TIMEOUT_MS = 3e4;
1253
+ async function runGitWithInput(args, cwd, input, options = {}) {
1254
+ const { allowedExitCodes = [], timeoutMs = GIT_INPUT_TIMEOUT_MS } = options;
1255
+ return new Promise((resolve2, reject) => {
1256
+ const child = spawn2("git", [...SAFE_GIT_CONFIG, ...args], { cwd });
1257
+ const stdout = [];
1258
+ const stderr = [];
1259
+ let settled = false;
1260
+ const settle = (finish) => {
1261
+ if (settled) return;
1262
+ settled = true;
1263
+ clearTimeout(timer);
1264
+ finish();
1265
+ };
1266
+ const timer = setTimeout(() => {
1267
+ child.kill("SIGKILL");
1268
+ settle(() => reject(new Error(`git ${args[0]} timed out after ${timeoutMs} ms`)));
1269
+ }, timeoutMs);
1270
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
1271
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
1272
+ child.stdin.on("error", () => {
1273
+ });
1274
+ child.on("error", (error2) => settle(() => reject(error2)));
1275
+ child.on(
1276
+ "close",
1277
+ (code) => settle(() => {
1278
+ if (code === 0 || code !== null && allowedExitCodes.includes(code)) {
1279
+ resolve2(Buffer.concat(stdout).toString("utf8"));
1280
+ return;
1281
+ }
1282
+ const message = Buffer.concat(stderr).toString("utf8").trim();
1283
+ reject(new Error(`git ${args[0]} exited with ${code}${message ? `: ${message}` : ""}`));
1284
+ })
1285
+ );
1286
+ child.stdin.end(input);
1287
+ });
1288
+ }
1242
1289
 
1243
1290
  // src/sessions/files/plan-file.ts
1244
1291
  import { constants as constants2 } from "fs";
@@ -1418,6 +1465,12 @@ async function runTmux(args, options) {
1418
1465
  async function sendKeysLiteral(tmuxSessionName, text) {
1419
1466
  await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text]);
1420
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
+ }
1421
1474
  async function capturePane(tmuxSessionName) {
1422
1475
  return runTmux(["capture-pane", "-t", tmuxSessionName, "-p", "-e"]);
1423
1476
  }
@@ -2229,7 +2282,7 @@ function listInstalledAgents(env = process.env) {
2229
2282
  }
2230
2283
 
2231
2284
  // src/version.ts
2232
- var DAEMON_VERSION = true ? "0.1.13" : "dev";
2285
+ var DAEMON_VERSION = true ? "0.1.15" : "dev";
2233
2286
  var VERSION_FLAGS = ["--version", "-v"];
2234
2287
  function versionLine() {
2235
2288
  return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
@@ -2406,6 +2459,7 @@ function verifyRejectionMessage(reason) {
2406
2459
  var REVOKED_REJECTION_MESSAGE = "connection has not completed its handshake";
2407
2460
  var BRACKETED_PASTE_START = "\x1B[200~";
2408
2461
  var BRACKETED_PASTE_END = "\x1B[201~";
2462
+ var WHEEL_ONLY_INPUT = /^(?:\x1b\[<6[45];\d+;\d+M)+$/;
2409
2463
  function releaseSession(conn, ptyAttachments, sessionId) {
2410
2464
  const listener = conn.getOutputListener(sessionId);
2411
2465
  if (listener) ptyAttachments.release(sessionId, listener);
@@ -2419,6 +2473,7 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
2419
2473
  let admissionChain = Promise.resolve();
2420
2474
  const conn = {
2421
2475
  subscriptions: /* @__PURE__ */ new Set(),
2476
+ mouseModeSessions: /* @__PURE__ */ new Map(),
2422
2477
  send,
2423
2478
  bufferedBytes,
2424
2479
  isClosed: () => closed,
@@ -2457,6 +2512,11 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
2457
2512
  for (const sessionId of [...conn.subscriptions]) {
2458
2513
  releaseSession(conn, ptyAttachments, sessionId);
2459
2514
  }
2515
+ for (const tmuxSessionName of conn.mouseModeSessions.values()) {
2516
+ setMouseMode(tmuxSessionName, false).catch(() => {
2517
+ });
2518
+ }
2519
+ conn.mouseModeSessions.clear();
2460
2520
  }
2461
2521
  return { conn, releaseAllSubscriptions };
2462
2522
  }
@@ -2690,6 +2750,11 @@ async function dispatch(conn, message, {
2690
2750
  return;
2691
2751
  }
2692
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
+ }
2693
2758
  ptyAttachments.write(message.sessionId, message.data);
2694
2759
  return;
2695
2760
  }
@@ -2720,6 +2785,20 @@ async function dispatch(conn, message, {
2720
2785
  ptyAttachments.resize(message.sessionId, message.cols, message.rows);
2721
2786
  return;
2722
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
+ }
2723
2802
  case "session.stop": {
2724
2803
  try {
2725
2804
  if (!await sessionManager.stop(message.sessionId)) {
@@ -3934,11 +4013,27 @@ function wrapCommandWithSetup(input) {
3934
4013
  ].join(" ");
3935
4014
  }
3936
4015
 
4016
+ // src/sessions/worktree-setup/skipped-files.ts
4017
+ var SETUP_SKIPPED_OPTION = "@bondsmith-setup-skipped";
4018
+ function encodeSkippedFiles(names) {
4019
+ return names.slice(0, MAX_SKIPPED_FILES_REPORTED).map(encodeURIComponent).join(",");
4020
+ }
4021
+ function decodeSkippedFiles(raw) {
4022
+ if (raw === "") return [];
4023
+ try {
4024
+ return raw.split(",").map(decodeURIComponent);
4025
+ } catch {
4026
+ return [];
4027
+ }
4028
+ }
4029
+
3937
4030
  // src/sessions/tmux/agent-state.ts
3938
4031
  var AGENT_STATE_OPTION = "@bondsmith-agent-state";
3939
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;
3940
4035
  var MAX_HEADLINE_CHARS = 200;
3941
- var LIVE_SESSION_REPORTS_FORMAT = `#{session_name} #{${AGENT_STATE_OPTION}} #{window_bell_flag} #{${SETUP_FAILED_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}}`;
3942
4037
  var AGENT_STATE_OUTPUT_GRACE_MS = 2e3;
3943
4038
  var FUTURE_REPORT_ALLOWANCE_MS = 2e3;
3944
4039
  var WORKING_SILENCE_MS = 12e4;
@@ -3950,29 +4045,48 @@ function cleanHeadline(raw) {
3950
4045
  if (!line) return null;
3951
4046
  return line.length > MAX_HEADLINE_CHARS ? `${line.slice(0, MAX_HEADLINE_CHARS - 1)}\u2026` : line;
3952
4047
  }
4048
+ function parsePullRequestUrls(raw) {
4049
+ const urls = [...new Set(raw.match(PULL_REQUEST_URL) ?? [])];
4050
+ return urls.slice(-MAX_SESSION_PULL_REQUESTS);
4051
+ }
3953
4052
  function isAgentState(value) {
3954
4053
  return AGENT_STATES.includes(value);
3955
4054
  }
3956
4055
  function parseLiveSessionReports(output) {
3957
4056
  const reports = /* @__PURE__ */ new Map();
3958
4057
  for (const line of output.split("\n")) {
3959
- const [sessionName, agentState = "", bell = "", setupFailed = "", ...headline] = line.split(" ");
4058
+ const [
4059
+ sessionName,
4060
+ agentState = "",
4061
+ bell = "",
4062
+ setupFailed = "",
4063
+ setupSkipped = "",
4064
+ pullRequests = "",
4065
+ ...headline
4066
+ ] = line.split(" ");
3960
4067
  if (sessionName) {
3961
4068
  reports.set(sessionName, {
3962
4069
  agentState,
3963
4070
  bell: bell === "1",
3964
4071
  setupFailed: setupFailed === "1",
4072
+ setupSkipped: decodeSkippedFiles(setupSkipped),
4073
+ pullRequestUrls: parsePullRequestUrls(pullRequests),
3965
4074
  headline: cleanHeadline(headline.join(" "))
3966
4075
  });
3967
4076
  }
3968
4077
  }
3969
4078
  return reports;
3970
4079
  }
3971
- function resolveAgentState(raw, lastActivityAt, nowMs = Date.now()) {
4080
+ function parseReport(raw) {
3972
4081
  if (!raw) return null;
3973
4082
  const [state, epoch] = raw.split(":");
3974
4083
  if (!state || !isAgentState(state) || !epoch || !/^\d+$/.test(epoch)) return null;
3975
- 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;
3976
4090
  if (reportedAtMs > nowMs + FUTURE_REPORT_ALLOWANCE_MS) return null;
3977
4091
  if (SETTLED_STATES.has(state) && lastActivityAt) {
3978
4092
  if (Date.parse(lastActivityAt) > reportedAtMs + AGENT_STATE_OUTPUT_GRACE_MS) return null;
@@ -3992,9 +4106,16 @@ function hasSettled(lastActivityAt, acknowledgedAtMs, nowMs = Date.now()) {
3992
4106
  }
3993
4107
  return nowMs - lastActivityMs >= SETTLE_QUIET_MS;
3994
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
+ }
3995
4115
  function resolveSessionAttention(report, lastActivityAt, { nowMs, acknowledgedAtMs = null } = {}) {
3996
4116
  if (report?.bell) return "waiting";
3997
4117
  const reported = resolveAgentState(report?.agentState, lastActivityAt, nowMs);
4118
+ if (reported === "done" && isDoneAcknowledged(report?.agentState, acknowledgedAtMs)) return null;
3998
4119
  if (reported) return reported;
3999
4120
  return hasSettled(lastActivityAt, acknowledgedAtMs, nowMs) ? "waiting" : null;
4000
4121
  }
@@ -4045,6 +4166,21 @@ function headlineHookScript() {
4045
4166
  `}catch{}})`
4046
4167
  ].join("");
4047
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
+ }
4048
4184
 
4049
4185
  // src/sessions/agents/claude-settings.ts
4050
4186
  function shellQuote3(value) {
@@ -4059,12 +4195,17 @@ function planHookCommand(nodePath = process.execPath, plansDir = HOOK_PLANS_DIR)
4059
4195
  function headlineHookCommand(nodePath = process.execPath) {
4060
4196
  return nodeHookCommand(headlineHookScript(), nodePath);
4061
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
+ }
4062
4202
  function stateHookCommand(state) {
4063
4203
  return `[ -n "$${SESSION_ENV_VAR}" ] && tmux set-option -t "$TMUX_PANE" -q ${AGENT_STATE_OPTION} "${state}:$(date +%s)"; exit 0`;
4064
4204
  }
4065
4205
  var STATE_HOOK_TIMEOUT_SECONDS = 5;
4066
4206
  var PLAN_HOOK_TIMEOUT_SECONDS = 10;
4067
4207
  var HEADLINE_HOOK_TIMEOUT_SECONDS = 5;
4208
+ var PULL_REQUEST_HOOK_TIMEOUT_SECONDS = 5;
4068
4209
  function claudeSettings() {
4069
4210
  const run = (command, timeout) => ({ type: "command", command, timeout });
4070
4211
  const report = (state) => run(stateHookCommand(state), STATE_HOOK_TIMEOUT_SECONDS);
@@ -4074,16 +4215,20 @@ function claudeSettings() {
4074
4215
  {
4075
4216
  matcher: "ExitPlanMode",
4076
4217
  hooks: [run(planHookCommand(), PLAN_HOOK_TIMEOUT_SECONDS)]
4218
+ },
4219
+ {
4220
+ matcher: "Bash",
4221
+ hooks: [run(pullRequestHookCommand(), PULL_REQUEST_HOOK_TIMEOUT_SECONDS)]
4077
4222
  }
4078
4223
  ],
4079
- // 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.
4080
4225
  UserPromptSubmit: [
4081
4226
  {
4082
4227
  hooks: [report("working"), run(headlineHookCommand(), HEADLINE_HOOK_TIMEOUT_SECONDS)]
4083
4228
  }
4084
4229
  ],
4085
4230
  PermissionRequest: [{ matcher: "*", hooks: [report("waiting")] }],
4086
- Stop: [{ hooks: [report("waiting")] }],
4231
+ Stop: [{ hooks: [report("done")] }],
4087
4232
  StopFailure: [{ hooks: [report("waiting")] }]
4088
4233
  }
4089
4234
  };
@@ -4190,19 +4335,74 @@ async function resolveWorktreeStartPoint(sourceCwd, baseBranch) {
4190
4335
  import { constants as constants4 } from "fs";
4191
4336
  import { copyFile, glob, lstat as lstat3, mkdir as mkdir5, realpath as realpath2 } from "fs/promises";
4192
4337
  import { dirname as dirname4, isAbsolute as isAbsolute3, join as join10, relative, sep as sep2 } from "path";
4338
+ var GIT_EXIT_NONE_IGNORED = 1;
4339
+ var TRACKED_CHECK_BATCH = 200;
4193
4340
  function isInside(root, path) {
4194
4341
  const rel = relative(root, path);
4195
4342
  return rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute3(rel);
4196
4343
  }
4344
+ async function ignoredPaths(worktree, paths) {
4345
+ if (paths.length === 0) return /* @__PURE__ */ new Set();
4346
+ const output = await runGitWithInput(
4347
+ ["check-ignore", "--stdin", "-z"],
4348
+ worktree,
4349
+ paths.map((path) => `${path}\0`).join(""),
4350
+ { allowedExitCodes: [GIT_EXIT_NONE_IGNORED] }
4351
+ );
4352
+ return new Set(output.split("\0").filter((path) => path !== ""));
4353
+ }
4354
+ async function trackedPaths(worktree, paths) {
4355
+ const tracked = /* @__PURE__ */ new Set();
4356
+ for (let start2 = 0; start2 < paths.length; start2 += TRACKED_CHECK_BATCH) {
4357
+ const output = await runGit(
4358
+ [
4359
+ "--literal-pathspecs",
4360
+ "ls-files",
4361
+ "-z",
4362
+ "--",
4363
+ ...paths.slice(start2, start2 + TRACKED_CHECK_BATCH)
4364
+ ],
4365
+ worktree
4366
+ );
4367
+ for (const path of output.split("\0")) if (path !== "") tracked.add(path);
4368
+ }
4369
+ return tracked;
4370
+ }
4197
4371
  async function copyFilesIntoWorktree(sourceRoot, worktreeRoot, globs) {
4198
- const result = { copied: [], failed: 0 };
4372
+ const result = { copied: [], failed: 0, skipped: [] };
4199
4373
  const patterns = globs.filter(isSafeCopyGlob);
4200
4374
  if (patterns.length === 0) return result;
4201
4375
  try {
4202
4376
  const source = await realpath2(sourceRoot);
4203
4377
  const worktree = await realpath2(worktreeRoot);
4378
+ const candidates = [];
4204
4379
  for await (const match of glob(patterns, { cwd: source })) {
4205
4380
  if (match.split(sep2).includes(".git")) continue;
4381
+ try {
4382
+ if (await isCopyCandidate(source, worktree, match)) candidates.push(match);
4383
+ } catch (error2) {
4384
+ result.failed += 1;
4385
+ console.error("Failed to inspect a file to copy into the worktree", { match, error: error2 });
4386
+ }
4387
+ }
4388
+ let tracked;
4389
+ let ignored;
4390
+ try {
4391
+ tracked = await trackedPaths(worktree, candidates);
4392
+ ignored = await ignoredPaths(worktree, candidates);
4393
+ } catch (error2) {
4394
+ result.failed += candidates.length;
4395
+ console.error("Could not ask git what the worktree tracks and ignores; copying nothing", {
4396
+ error: error2
4397
+ });
4398
+ return result;
4399
+ }
4400
+ for (const match of candidates) {
4401
+ if (tracked.has(match)) continue;
4402
+ if (!ignored.has(match)) {
4403
+ result.skipped.push(match);
4404
+ continue;
4405
+ }
4206
4406
  try {
4207
4407
  if (await copyOne(source, worktree, match)) result.copied.push(match);
4208
4408
  } catch (error2) {
@@ -4216,15 +4416,24 @@ async function copyFilesIntoWorktree(sourceRoot, worktreeRoot, globs) {
4216
4416
  }
4217
4417
  return result;
4218
4418
  }
4219
- async function copyOne(source, worktree, match) {
4419
+ async function isCopyCandidate(source, worktree, match) {
4220
4420
  const from = join10(source, match);
4221
4421
  if (!(await lstat3(from)).isFile()) return false;
4222
4422
  if (!isInside(source, await realpath2(from))) return false;
4423
+ try {
4424
+ await lstat3(join10(worktree, match));
4425
+ return false;
4426
+ } catch (error2) {
4427
+ if (error2.code === "ENOENT") return true;
4428
+ throw error2;
4429
+ }
4430
+ }
4431
+ async function copyOne(source, worktree, match) {
4223
4432
  const to = join10(worktree, match);
4224
4433
  await mkdir5(dirname4(to), { recursive: true });
4225
4434
  if (!isInside(worktree, await realpath2(dirname4(to)))) return false;
4226
4435
  try {
4227
- await copyFile(from, to, constants4.COPYFILE_EXCL);
4436
+ await copyFile(join10(source, match), to, constants4.COPYFILE_EXCL);
4228
4437
  } catch (error2) {
4229
4438
  if (error2.code === "EEXIST") return false;
4230
4439
  throw error2;
@@ -4247,6 +4456,7 @@ async function createTmuxSession(input) {
4247
4456
  let cwd = sourceCwd;
4248
4457
  let worktreeOf = null;
4249
4458
  let setupFailed = false;
4459
+ let skippedFiles = [];
4250
4460
  if (input.worktreeBranch) {
4251
4461
  const worktreePath = worktreePathFor(input.id);
4252
4462
  const startPoint = await resolveWorktreeStartPoint(sourceCwd, input.baseBranch);
@@ -4260,6 +4470,7 @@ async function createTmuxSession(input) {
4260
4470
  input.worktreeSetup.copyGlobs
4261
4471
  );
4262
4472
  setupFailed = copy.failed > 0;
4473
+ skippedFiles = copy.skipped;
4263
4474
  }
4264
4475
  }
4265
4476
  try {
@@ -4297,6 +4508,15 @@ async function createTmuxSession(input) {
4297
4508
  await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-worktree-of", worktreeOf]);
4298
4509
  }
4299
4510
  if (setupFailed) await runTmux(["set-option", "-t", tmuxSessionName, SETUP_FAILED_OPTION, "1"]);
4511
+ if (skippedFiles.length > 0) {
4512
+ await runTmux([
4513
+ "set-option",
4514
+ "-t",
4515
+ tmuxSessionName,
4516
+ SETUP_SKIPPED_OPTION,
4517
+ encodeSkippedFiles(skippedFiles)
4518
+ ]);
4519
+ }
4300
4520
  if (baseline) {
4301
4521
  await runTmux([
4302
4522
  "set-option",
@@ -4319,7 +4539,10 @@ async function createTmuxSession(input) {
4319
4539
  lastActivityAt: null,
4320
4540
  changedFileCount: null,
4321
4541
  worktreeOf,
4322
- ...setupFailed && { worktreeSetupFailed: true }
4542
+ ...setupFailed && { worktreeSetupFailed: true },
4543
+ ...skippedFiles.length > 0 && {
4544
+ worktreeSetupSkipped: skippedFiles.slice(0, MAX_SKIPPED_FILES_REPORTED)
4545
+ }
4323
4546
  };
4324
4547
  return { session: await enrichSessionWithGitMetadata(session, baseline), baseline };
4325
4548
  }
@@ -4515,17 +4738,16 @@ ${error2.message}
4515
4738
  session,
4516
4739
  this.baselines.get(session.id)
4517
4740
  );
4741
+ const report = agentReports.get(session.tmuxSessionName);
4518
4742
  const updated = {
4519
4743
  ...enriched,
4520
- agentState: resolveSessionAttention(
4521
- agentReports.get(session.tmuxSessionName),
4522
- enriched.lastActivityAt,
4523
- { acknowledgedAtMs: this.acknowledgements.get(session.id) ?? null }
4524
- ),
4525
- headline: agentReports.get(session.tmuxSessionName)?.headline ?? null,
4526
- ...agentReports.get(session.tmuxSessionName)?.setupFailed && {
4527
- worktreeSetupFailed: true
4528
- }
4744
+ agentState: resolveSessionAttention(report, enriched.lastActivityAt, {
4745
+ acknowledgedAtMs: this.acknowledgements.get(session.id) ?? null
4746
+ }),
4747
+ headline: report?.headline ?? null,
4748
+ ...report?.pullRequestUrls?.length && { pullRequestUrls: report.pullRequestUrls },
4749
+ ...report?.setupFailed && { worktreeSetupFailed: true },
4750
+ ...report?.setupSkipped?.length && { worktreeSetupSkipped: report.setupSkipped }
4529
4751
  };
4530
4752
  if (!snapshotEquals(session, updated)) {
4531
4753
  this.sessions.set(session.id, updated);
@@ -4550,7 +4772,7 @@ ${error2.message}
4550
4772
  }
4551
4773
  };
4552
4774
  function snapshotEquals(a, b) {
4553
- 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);
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 ?? []);
4554
4776
  }
4555
4777
 
4556
4778
  // src/ws/server.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bondsmith/bondsmithd",
3
- "version": "0.1.13",
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",