@bondsmith/bondsmithd 0.1.15 → 0.1.16

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 +34 -11
  2. package/dist/index.js +477 -248
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -143,21 +143,17 @@ A hook can store the plan outside the repository instead, in `~/.bondsmith/plans
143
143
  option, so a plan written there is never left over from an earlier session.
144
144
 
145
145
  For Claude Code the daemon sets this up itself. When a session's command is `claude` (that word alone, not
146
- behind `npx`, an environment variable or a wrapper), the daemon starts it as
146
+ behind `npx` or a wrapper; `VAR=value` words before it are fine), the daemon starts it as
147
147
  `claude --settings ~/.bondsmith/hooks/claude-settings.json …`. That file adds hooks to the ones you
148
- already have: after you approve a plan in plan mode, one stores the plan as above, and four report the agent
149
- state (`working` on a prompt; `waiting` on a permission request, the end of a turn, or a turn that fails with
148
+ already have: one stores the plan as above as soon as plan mode proposes it, before you approve it;
149
+ another logs the agent's task calls (`TaskCreate`, `TaskUpdate`, `TodoWrite`) to `~/.bondsmith/plans/<session id>.tasks.jsonl`,
150
+ which the daemon replays into the session's task list (shown under the plan in the browser); and four
151
+ report the agent state (`working` on a prompt; `waiting` on a permission request, the end of a turn, or a turn that fails with
150
152
  an API error). Nothing in `~/.claude` changes, the session still shows the command you typed, and the hooks
151
153
  do nothing outside a Bondsmith session. Claude Code's `/hooks` menu does not list hooks passed with
152
154
  `--settings`, though they run.
153
155
 
154
- A fifth hook stores the latest prompt as the session's headline, which the sessions list and the session bar
155
- show. It is the prompt on one line, cut at 200 characters, in the tmux option `@bondsmith-headline`. A reply
156
- that only answers the agent (`yes`, `continue`, `ok`, `thanks`, and a few like them) or a prompt under 4
157
- characters leaves the earlier headline in place; a built-in slash command such as `/help` sends no prompt to
158
- the hook, so it does too. The prompt is stored as typed, so a secret in a prompt is in the headline.
159
-
160
- A sixth hook watches Bash calls for `gh pr create`. When one succeeds and prints a pull request URL, the URL is
156
+ A fifth hook watches Bash calls for `gh pr create`. When one succeeds and prints a pull request URL, the URL is
161
157
  appended to the tmux option `@bondsmith-prs` (space separated, each once, the newest 20 kept), and the daemon
162
158
  reports it on the session as `pullRequestUrls`. It matches `gh pr create` only as a command of its own, so
163
159
  `echo "gh pr create"` or `gh pr view` record nothing, and a failed command never reaches it.
@@ -165,7 +161,7 @@ reports it on the session as `pullRequestUrls`. It matches `gh pr create` only a
165
161
  The daemon leaves the command alone if it already has `--settings`, if the installed `claude` does not list
166
162
  `--settings` in `claude --help` (asked once per daemon run; if `claude` cannot be run at all, that is logged
167
163
  and asked again after a minute), or if `BONDSMITH_AGENT_HOOKS` is set to `off`, `0`, `false` or `no` where
168
- the daemon starts. Each hook has a timeout (5 seconds for a state report or the headline, 10 for the plan), so a stuck command
164
+ the daemon starts. Each hook has a timeout (5 seconds for a state report, 10 for the plan and the task list), so a stuck command
169
165
  cannot hold up the agent. Plans are written to `~/.bondsmith/plans` readable only by you, and a symlink in
170
166
  the way is replaced, not followed. A plan the agent changes later without going back through plan mode is
171
167
  not seen; the hook's plan keeps winning over the folder's file until the next approval.
@@ -209,6 +205,33 @@ tmux only flags a bell when no client is attached to the session or the bell is
209
205
  the one the attached client has open. A session whose terminal is open in a browser tab, even a hidden
210
206
  one, counts as being looked at, so a bell in it is not flagged; use the hook report for that case.
211
207
 
208
+ ## Adding an agent
209
+
210
+ The daemon splits what is the same for every agent from what is one agent's own.
211
+
212
+ | Part | Where | Agent-specific? |
213
+ | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------- |
214
+ | What an agent reports: state, plan, task list, pull requests (the channels below) | `src/sessions/agent-reports/` | No |
215
+ | Turning reports into what the UI shows (`readSessionReport`: the plan and the task list for `getPlan`; attention state) | `agent-reports/`, `tmux/agent-state.ts` | No |
216
+ | Recognising an agent's command and launching it so it reports | `src/sessions/agents/<agent>/` | Yes |
217
+ | The list of agents | `AGENT_IDS` in `@bondsmith/local-agent-protocol`; `agents/registry.ts` | Add one line each |
218
+
219
+ An agent reports through these channels, and the daemon reads them without caring who wrote them:
220
+
221
+ | Report | Channel |
222
+ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
223
+ | State | tmux option `@bondsmith-agent-state` = `<working\|waiting\|done>:<epoch seconds>` on the session's pane; or ring the bell; or nothing (a pane gone quiet counts as waiting) |
224
+ | Plan | `~/.bondsmith/plans/<session id>.md`, or `.bondsmith/plan.md` in the session's folder, or tmux option `@bondsmith-plan` |
225
+ | Task list | `~/.bondsmith/plans/<session id>.tasks.jsonl`: one JSON `TaskEvent` per line, appended (`add`, `update`, `remove`, `replace`; see `agent-reports/task-list.ts`) |
226
+ | Pull requests | tmux option `@bondsmith-prs`, space-separated URLs |
227
+
228
+ To add an agent: add its id to `AGENT_IDS`, which makes `registry.ts` refuse to compile until it has an adapter. A new
229
+ `AgentAdapter` needs `matches(command)` and `prepareCommand(command)`. With nothing to set up use `plainAdapter(id)`,
230
+ as Codex and opencode do today (they report through the bell and the quiet-pane check). With hooks, put the agent's
231
+ settings and payload mapping in `agents/<agent>/` and build its hook commands from the helpers in
232
+ `agent-reports/hook-commands.ts` and `agent-reports/hook-scripts.ts`; the agent's file only says where in its
233
+ payload the plan, the task event and the shell command are. `agents/claude/` is the worked example.
234
+
212
235
  ## Production connectivity (current status)
213
236
 
214
237
  `bondsmithd` connects outbound to the Bondsmith relay by default (`BONDSMITH_RELAY_ENABLED=true`,
package/dist/index.js CHANGED
@@ -4,12 +4,14 @@
4
4
  import { createServer } from "http";
5
5
  import { existsSync } from "fs";
6
6
  import { hostname as hostname2 } from "os";
7
- import { dirname as dirname5, join as join12 } from "path";
7
+ import { dirname as dirname6, join as join13 } 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
12
  var AGENT_STATES = ["working", "waiting", "done"];
13
+ var AGENT_IDS = ["claude", "codex", "opencode"];
14
+ var TASK_STATUSES = ["pending", "in_progress", "completed"];
13
15
  var MAX_SKIPPED_FILES_REPORTED = 20;
14
16
  var MAX_SESSION_PULL_REQUESTS = 20;
15
17
 
@@ -18,7 +20,7 @@ var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
18
20
  var INVALID_MESSAGE_ERROR = "invalid message";
19
21
 
20
22
  // ../../packages/local-agent-protocol/src/protocol-version.ts
21
- var PROTOCOL_VERSION = 19;
23
+ var PROTOCOL_VERSION = 20;
22
24
 
23
25
  // ../../packages/local-agent-protocol/src/branch-name.ts
24
26
  var MAX_BRANCH_NAME_LENGTH = 250;
@@ -185,6 +187,8 @@ var MAX_WIRE_MESSAGE_BYTES = 256 * 1024;
185
187
  var MAX_DAEMON_REPLY_BYTES = 200 * 1024;
186
188
  var MAX_RANGE_LINES = 500;
187
189
  var MAX_PLAN_CHARS = 32 * 1024;
190
+ var MAX_PLAN_TASKS = 50;
191
+ var MAX_TASK_TEXT_CHARS = 120;
188
192
  var MAX_TERMINAL_DIMENSION = 1e3;
189
193
  var terminalSizeSchema = z3.object({
190
194
  cols: z3.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
@@ -205,9 +209,9 @@ var localSessionSchema = z3.object({
205
209
  worktreeOf: z3.string().nullable(),
206
210
  // .catch(undefined): a state this version doesn't know must never drop the whole session message.
207
211
  agentState: z3.enum(AGENT_STATES).nullable().optional().catch(void 0),
212
+ agent: z3.enum(AGENT_IDS).nullable().optional().catch(void 0),
208
213
  worktreeSetupFailed: z3.boolean().optional().catch(void 0),
209
214
  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
215
  pullRequestUrls: z3.array(z3.string().max(300)).max(MAX_SESSION_PULL_REQUESTS).optional().catch(void 0)
212
216
  });
213
217
  var workspaceFileEntrySchema = z3.object({
@@ -388,6 +392,11 @@ var connectionAuthenticationFailureReasonSchema = z3.enum([
388
392
  "key_agreement_failed"
389
393
  ]);
390
394
  var connectionRejectionReasonSchema = z3.enum(["origin_not_allowed", "token_invalid"]);
395
+ var sessionTaskSchema = z3.object({
396
+ subject: z3.string().max(MAX_TASK_TEXT_CHARS),
397
+ activeForm: z3.string().max(MAX_TASK_TEXT_CHARS).optional(),
398
+ status: z3.enum(TASK_STATUSES)
399
+ });
391
400
  var daemonMessageSchema = z3.discriminatedUnion("type", [
392
401
  z3.object({ type: z3.literal("session.list"), sessions: z3.array(localSessionSchema) }),
393
402
  z3.object({
@@ -445,7 +454,8 @@ var daemonMessageSchema = z3.discriminatedUnion("type", [
445
454
  type: z3.literal("workspace.plan"),
446
455
  sessionId: z3.string(),
447
456
  requestId: z3.string(),
448
- plan: z3.string().max(MAX_PLAN_CHARS).nullable()
457
+ plan: z3.string().max(MAX_PLAN_CHARS).nullable(),
458
+ tasks: z3.array(sessionTaskSchema).max(MAX_PLAN_TASKS).default([])
449
459
  }),
450
460
  z3.object({
451
461
  type: z3.literal("project.pathValidated"),
@@ -1043,7 +1053,7 @@ async function callTRPCQuery(baseUrl, procedure, input) {
1043
1053
  // src/pairing/pairing.ts
1044
1054
  var DEFAULT_POLL_INTERVAL_MS = 3e3;
1045
1055
  function sleep(ms) {
1046
- return new Promise((resolve2) => setTimeout(resolve2, ms));
1056
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1047
1057
  }
1048
1058
  async function ensurePaired(options) {
1049
1059
  const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
@@ -1181,8 +1191,8 @@ async function bindFirstController(params, response) {
1181
1191
  }
1182
1192
 
1183
1193
  // src/projects/validate-path.ts
1184
- import { stat as stat2 } from "fs/promises";
1185
- import { isAbsolute as isAbsolute2, resolve } from "path";
1194
+ import { stat } from "fs/promises";
1195
+ import { isAbsolute, resolve } from "path";
1186
1196
 
1187
1197
  // src/sessions/expand-home.ts
1188
1198
  import { homedir } from "os";
@@ -1252,7 +1262,7 @@ function runGitDiffAllowingExitCodes(args, cwd, allowedExitCodes) {
1252
1262
  var GIT_INPUT_TIMEOUT_MS = 3e4;
1253
1263
  async function runGitWithInput(args, cwd, input, options = {}) {
1254
1264
  const { allowedExitCodes = [], timeoutMs = GIT_INPUT_TIMEOUT_MS } = options;
1255
- return new Promise((resolve2, reject) => {
1265
+ return new Promise((resolve3, reject) => {
1256
1266
  const child = spawn2("git", [...SAFE_GIT_CONFIG, ...args], { cwd });
1257
1267
  const stdout = [];
1258
1268
  const stderr = [];
@@ -1276,7 +1286,7 @@ async function runGitWithInput(args, cwd, input, options = {}) {
1276
1286
  "close",
1277
1287
  (code) => settle(() => {
1278
1288
  if (code === 0 || code !== null && allowedExitCodes.includes(code)) {
1279
- resolve2(Buffer.concat(stdout).toString("utf8"));
1289
+ resolve3(Buffer.concat(stdout).toString("utf8"));
1280
1290
  return;
1281
1291
  }
1282
1292
  const message = Buffer.concat(stderr).toString("utf8").trim();
@@ -1287,134 +1297,13 @@ async function runGitWithInput(args, cwd, input, options = {}) {
1287
1297
  });
1288
1298
  }
1289
1299
 
1290
- // src/sessions/files/plan-file.ts
1291
- import { constants as constants2 } from "fs";
1292
- import { open as open2 } from "fs/promises";
1300
+ // src/sessions/agent-reports/layout.ts
1293
1301
  import { homedir as homedir2 } from "os";
1294
- import { join as join3 } from "path";
1295
-
1296
- // src/sessions/files/read-range.ts
1297
- import { constants } from "fs";
1298
- import { lstat, open, realpath, stat } from "fs/promises";
1299
- import { isAbsolute, join as join2, sep } from "path";
1300
- var MAX_RANGE_FILE_BYTES = 2 * 1024 * 1024;
1301
- var REPLY_ENVELOPE_BYTES = 8 * 1024;
1302
- var BINARY_SNIFF_BYTES = 8e3;
1303
- function fileVersion(stats) {
1304
- return `${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}:${stats.ino}`;
1305
- }
1306
- async function locateInside(cwd, path) {
1307
- if (isAbsolute(path)) return { refused: "not_allowed" };
1308
- try {
1309
- const root = await realpath(cwd);
1310
- const lexical = join2(root, path);
1311
- if ((await lstat(lexical)).isSymbolicLink()) return { refused: "not_allowed" };
1312
- const real = await realpath(lexical);
1313
- return real === root || real.startsWith(root + sep) ? { found: real } : { refused: "missing" };
1314
- } catch {
1315
- return { refused: "missing" };
1316
- }
1317
- }
1318
- async function readExactly(handle, size) {
1319
- const buffer = Buffer.allocUnsafe(size + 1);
1320
- let filled = 0;
1321
- while (filled < buffer.length) {
1322
- const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
1323
- if (bytesRead === 0) break;
1324
- filled += bytesRead;
1325
- }
1326
- return filled === size ? buffer.subarray(0, size) : void 0;
1327
- }
1328
- async function currentFileVersion(cwd, path) {
1329
- const located = await locateInside(cwd, path);
1330
- if (!("found" in located)) return void 0;
1331
- try {
1332
- return fileVersion(await stat(located.found));
1333
- } catch {
1334
- return void 0;
1335
- }
1336
- }
1337
- function splitLines(text) {
1338
- if (text === "") return [];
1339
- const lines = text.split("\n");
1340
- if (lines[lines.length - 1] === "") lines.pop();
1341
- return lines.map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
1342
- }
1343
- async function readFileRange(cwd, path, startLine, lineCount, version) {
1344
- const refuse = (reason) => ({ ok: false, path, reason });
1345
- const located = await locateInside(cwd, path);
1346
- if (!("found" in located)) return refuse(located.refused);
1347
- let content;
1348
- let handle;
1349
- try {
1350
- handle = await open(
1351
- located.found,
1352
- constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK
1353
- );
1354
- const before = await handle.stat();
1355
- if (!before.isFile()) return refuse("not_allowed");
1356
- if (fileVersion(before) !== version) return refuse("changed");
1357
- if (before.size > MAX_RANGE_FILE_BYTES) return refuse("too_large");
1358
- const bytes = await readExactly(handle, before.size);
1359
- if (bytes === void 0 || fileVersion(await handle.stat()) !== version) {
1360
- return refuse("changed");
1361
- }
1362
- content = bytes;
1363
- } catch {
1364
- return refuse("missing");
1365
- } finally {
1366
- await handle?.close();
1367
- }
1368
- if (content.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return refuse("binary");
1369
- const lines = splitLines(content.toString("utf8"));
1370
- const page = [];
1371
- let budget = MAX_DAEMON_REPLY_BYTES - REPLY_ENVELOPE_BYTES;
1372
- const wanted = Math.min(lineCount, MAX_RANGE_LINES);
1373
- for (let index = startLine - 1; index < lines.length && page.length < wanted; index++) {
1374
- const line = lines[index];
1375
- const cost = Buffer.byteLength(JSON.stringify(line)) + 1;
1376
- if (cost > budget) {
1377
- if (page.length === 0) return refuse("too_large");
1378
- break;
1379
- }
1380
- budget -= cost;
1381
- page.push(line);
1382
- }
1383
- return { ok: true, path, startLine, lines: page, totalLines: lines.length, version };
1384
- }
1385
-
1386
- // src/sessions/files/plan-file.ts
1302
+ import { join as join2 } from "path";
1303
+ var HOOK_PLANS_DIR = join2(homedir2(), ".bondsmith", "plans");
1304
+ var PLAN_FILE_SUFFIX = ".md";
1305
+ var TASKS_FILE_SUFFIX = ".tasks.jsonl";
1387
1306
  var PLAN_FILE_PATH = ".bondsmith/plan.md";
1388
- var MAX_PLAN_BYTES = MAX_PLAN_CHARS * 4;
1389
- var ABSENT_CODES = /* @__PURE__ */ new Set(["ENOENT", "ELOOP", "ENOTDIR"]);
1390
- var HOOK_PLANS_DIR = join3(homedir2(), ".bondsmith", "plans");
1391
- async function readPlanAt(root, relativePath) {
1392
- const located = await locateInside(root, relativePath);
1393
- if (!("found" in located)) return null;
1394
- let handle;
1395
- try {
1396
- handle = await open2(
1397
- located.found,
1398
- constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK
1399
- );
1400
- if (!(await handle.stat()).isFile()) return null;
1401
- const buffer = Buffer.alloc(MAX_PLAN_BYTES);
1402
- const { bytesRead } = await handle.read(buffer, 0, MAX_PLAN_BYTES, 0);
1403
- const plan = buffer.toString("utf8", 0, bytesRead).trim();
1404
- return plan ? plan.slice(0, MAX_PLAN_CHARS) : null;
1405
- } catch (error2) {
1406
- if (ABSENT_CODES.has(error2.code ?? "")) return null;
1407
- throw error2;
1408
- } finally {
1409
- await handle?.close();
1410
- }
1411
- }
1412
- function readPlanFile(cwd) {
1413
- return readPlanAt(cwd, PLAN_FILE_PATH);
1414
- }
1415
- function readHookPlan(sessionId, plansDir = HOOK_PLANS_DIR) {
1416
- return readPlanAt(plansDir, `${sessionId}.md`);
1417
- }
1418
1307
 
1419
1308
  // src/sessions/tmux/client.ts
1420
1309
  import { execFile as execFileCb2 } from "child_process";
@@ -1462,8 +1351,8 @@ async function runTmux(args, options) {
1462
1351
  throw error2;
1463
1352
  }
1464
1353
  }
1465
- async function sendKeysLiteral(tmuxSessionName, text) {
1466
- await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text]);
1354
+ async function sendKeysLiteral(tmuxSessionName, text2) {
1355
+ await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text2]);
1467
1356
  }
1468
1357
  async function setMouseMode(tmuxSessionName, enabled) {
1469
1358
  await runTmux(["set-option", "-t", tmuxSessionName, "mouse", enabled ? "on" : "off"]);
@@ -1507,8 +1396,8 @@ var NO_SERVER_MESSAGE_PATTERNS = [
1507
1396
  function isNoServerError(error2) {
1508
1397
  if (typeof error2 !== "object" || error2 === null) return false;
1509
1398
  const { message, stderr } = error2;
1510
- const text = [message, stderr].filter((part) => typeof part === "string").join("\n");
1511
- return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(text));
1399
+ const text2 = [message, stderr].filter((part) => typeof part === "string").join("\n");
1400
+ return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(text2));
1512
1401
  }
1513
1402
 
1514
1403
  // src/sessions/git/base.ts
@@ -1545,14 +1434,14 @@ async function resolveComparisonBase(cwd) {
1545
1434
  }
1546
1435
 
1547
1436
  // src/sessions/git/baseline.ts
1548
- import { lstat as lstat2 } from "fs/promises";
1549
- import { join as join4 } from "path";
1437
+ import { lstat } from "fs/promises";
1438
+ import { join as join3 } from "path";
1550
1439
  var SESSION_START_REF = "session start";
1551
1440
  var MAX_PREEXISTING_FILES = 200;
1552
1441
  var DELETED = "deleted";
1553
1442
  async function fingerprintFile(cwd, path) {
1554
1443
  try {
1555
- const stats = await lstat2(join4(cwd, path));
1444
+ const stats = await lstat(join3(cwd, path));
1556
1445
  return `${stats.size}:${Math.trunc(stats.mtimeMs)}`;
1557
1446
  } catch {
1558
1447
  return DELETED;
@@ -1876,23 +1765,23 @@ function gitFailure(error2, path) {
1876
1765
  "git isn't installed, or isn't on the PATH of the terminal running bondsmithd."
1877
1766
  );
1878
1767
  }
1879
- const text = typeof stderr === "string" ? stderr : "";
1880
- if (text.includes("dubious ownership")) {
1768
+ const text2 = typeof stderr === "string" ? stderr : "";
1769
+ if (text2.includes("dubious ownership")) {
1881
1770
  return new Error(
1882
1771
  `git doesn't trust this folder because another user owns it. If you trust it, run: git config --global --add safe.directory ${shellQuote(path)}`
1883
1772
  );
1884
1773
  }
1885
- const fatal = text.split("\n").find((line) => line.startsWith("fatal:"));
1774
+ const fatal = text2.split("\n").find((line) => line.startsWith("fatal:"));
1886
1775
  return new Error(
1887
1776
  fatal ?? (error2 instanceof Error ? error2.message : "git failed to inspect that folder.")
1888
1777
  );
1889
1778
  }
1890
1779
  async function validateProjectPath(input) {
1891
1780
  const expanded = expandHome(input.trim());
1892
- if (!isAbsolute2(expanded)) return { valid: false, reason: "relative_path" };
1781
+ if (!isAbsolute(expanded)) return { valid: false, reason: "relative_path" };
1893
1782
  const path = resolve(expanded);
1894
1783
  try {
1895
- const stats = await stat2(path);
1784
+ const stats = await stat(path);
1896
1785
  if (!stats.isDirectory()) return { valid: false, reason: "not_a_directory" };
1897
1786
  } catch (error2) {
1898
1787
  const reason = invalidReasonForFsError(error2);
@@ -1919,7 +1808,7 @@ import { randomBytes, verify as verify2 } from "crypto";
1919
1808
  // src/security/trusted-controllers.ts
1920
1809
  import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
1921
1810
  import { homedir as homedir3 } from "os";
1922
- import { dirname, join as join5 } from "path";
1811
+ import { dirname, join as join4 } from "path";
1923
1812
  import { z as z12 } from "zod";
1924
1813
  var trustedControllerSchema = z12.object({
1925
1814
  id: z12.string(),
@@ -1942,7 +1831,7 @@ var trustedControllersFileSchema = z12.object({
1942
1831
  controllers: z12.array(trustedControllerSchema)
1943
1832
  });
1944
1833
  function defaultTrustedControllersPath() {
1945
- return join5(homedir3(), ".bondsmith", "trusted-controllers.json");
1834
+ return join4(homedir3(), ".bondsmith", "trusted-controllers.json");
1946
1835
  }
1947
1836
  async function loadTrustedControllers(path = defaultTrustedControllersPath()) {
1948
1837
  let raw;
@@ -2263,12 +2152,12 @@ var ConnectionAuthState = class {
2263
2152
  };
2264
2153
 
2265
2154
  // src/sessions/agents/installed-agents.ts
2266
- import { accessSync, constants as constants3, statSync } from "fs";
2267
- import { delimiter, join as join6 } from "path";
2268
- var KNOWN_AGENTS = ["claude", "codex", "opencode"];
2155
+ import { accessSync, constants, statSync } from "fs";
2156
+ import { delimiter, join as join5 } from "path";
2157
+ var KNOWN_AGENTS = AGENT_IDS;
2269
2158
  function isExecutableFile(path) {
2270
2159
  try {
2271
- accessSync(path, constants3.X_OK);
2160
+ accessSync(path, constants.X_OK);
2272
2161
  return statSync(path).isFile();
2273
2162
  } catch {
2274
2163
  return false;
@@ -2277,12 +2166,12 @@ function isExecutableFile(path) {
2277
2166
  function listInstalledAgents(env = process.env) {
2278
2167
  const directories = (env.PATH ?? "").split(delimiter).filter((directory) => directory !== "");
2279
2168
  return KNOWN_AGENTS.filter(
2280
- (agent) => directories.some((directory) => isExecutableFile(join6(directory, agent)))
2169
+ (agent) => directories.some((directory) => isExecutableFile(join5(directory, agent)))
2281
2170
  );
2282
2171
  }
2283
2172
 
2284
2173
  // src/version.ts
2285
- var DAEMON_VERSION = true ? "0.1.15" : "dev";
2174
+ var DAEMON_VERSION = true ? "0.1.16" : "dev";
2286
2175
  var VERSION_FLAGS = ["--version", "-v"];
2287
2176
  function versionLine() {
2288
2177
  return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
@@ -2291,6 +2180,241 @@ function isVersionRequest(argv) {
2291
2180
  return argv.some((arg) => VERSION_FLAGS.includes(arg));
2292
2181
  }
2293
2182
 
2183
+ // src/sessions/agent-reports/read-file.ts
2184
+ import { constants as constants3 } from "fs";
2185
+ import { open as open2 } from "fs/promises";
2186
+
2187
+ // src/sessions/files/read-range.ts
2188
+ import { constants as constants2 } from "fs";
2189
+ import { lstat as lstat2, open, realpath, stat as stat2 } from "fs/promises";
2190
+ import { isAbsolute as isAbsolute2, join as join6, sep } from "path";
2191
+ var MAX_RANGE_FILE_BYTES = 2 * 1024 * 1024;
2192
+ var REPLY_ENVELOPE_BYTES = 8 * 1024;
2193
+ var BINARY_SNIFF_BYTES = 8e3;
2194
+ function fileVersion(stats) {
2195
+ return `${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}:${stats.ino}`;
2196
+ }
2197
+ async function locateInside(cwd, path) {
2198
+ if (isAbsolute2(path)) return { refused: "not_allowed" };
2199
+ try {
2200
+ const root = await realpath(cwd);
2201
+ const lexical = join6(root, path);
2202
+ if ((await lstat2(lexical)).isSymbolicLink()) return { refused: "not_allowed" };
2203
+ const real = await realpath(lexical);
2204
+ return real === root || real.startsWith(root + sep) ? { found: real } : { refused: "missing" };
2205
+ } catch {
2206
+ return { refused: "missing" };
2207
+ }
2208
+ }
2209
+ async function readExactly(handle, size) {
2210
+ const buffer = Buffer.allocUnsafe(size + 1);
2211
+ let filled = 0;
2212
+ while (filled < buffer.length) {
2213
+ const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
2214
+ if (bytesRead === 0) break;
2215
+ filled += bytesRead;
2216
+ }
2217
+ return filled === size ? buffer.subarray(0, size) : void 0;
2218
+ }
2219
+ async function currentFileVersion(cwd, path) {
2220
+ const located = await locateInside(cwd, path);
2221
+ if (!("found" in located)) return void 0;
2222
+ try {
2223
+ return fileVersion(await stat2(located.found));
2224
+ } catch {
2225
+ return void 0;
2226
+ }
2227
+ }
2228
+ function splitLines(text2) {
2229
+ if (text2 === "") return [];
2230
+ const lines = text2.split("\n");
2231
+ if (lines[lines.length - 1] === "") lines.pop();
2232
+ return lines.map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
2233
+ }
2234
+ async function readFileRange(cwd, path, startLine, lineCount, version) {
2235
+ const refuse = (reason) => ({ ok: false, path, reason });
2236
+ const located = await locateInside(cwd, path);
2237
+ if (!("found" in located)) return refuse(located.refused);
2238
+ let content;
2239
+ let handle;
2240
+ try {
2241
+ handle = await open(
2242
+ located.found,
2243
+ constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK
2244
+ );
2245
+ const before = await handle.stat();
2246
+ if (!before.isFile()) return refuse("not_allowed");
2247
+ if (fileVersion(before) !== version) return refuse("changed");
2248
+ if (before.size > MAX_RANGE_FILE_BYTES) return refuse("too_large");
2249
+ const bytes = await readExactly(handle, before.size);
2250
+ if (bytes === void 0 || fileVersion(await handle.stat()) !== version) {
2251
+ return refuse("changed");
2252
+ }
2253
+ content = bytes;
2254
+ } catch {
2255
+ return refuse("missing");
2256
+ } finally {
2257
+ await handle?.close();
2258
+ }
2259
+ if (content.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return refuse("binary");
2260
+ const lines = splitLines(content.toString("utf8"));
2261
+ const page = [];
2262
+ let budget = MAX_DAEMON_REPLY_BYTES - REPLY_ENVELOPE_BYTES;
2263
+ const wanted = Math.min(lineCount, MAX_RANGE_LINES);
2264
+ for (let index = startLine - 1; index < lines.length && page.length < wanted; index++) {
2265
+ const line = lines[index];
2266
+ const cost = Buffer.byteLength(JSON.stringify(line)) + 1;
2267
+ if (cost > budget) {
2268
+ if (page.length === 0) return refuse("too_large");
2269
+ break;
2270
+ }
2271
+ budget -= cost;
2272
+ page.push(line);
2273
+ }
2274
+ return { ok: true, path, startLine, lines: page, totalLines: lines.length, version };
2275
+ }
2276
+
2277
+ // src/sessions/agent-reports/read-file.ts
2278
+ var ABSENT_CODES = /* @__PURE__ */ new Set(["ENOENT", "ELOOP", "ENOTDIR"]);
2279
+ async function readFileAt(root, relativePath, read) {
2280
+ const located = await locateInside(root, relativePath);
2281
+ if (!("found" in located)) return null;
2282
+ let handle;
2283
+ try {
2284
+ handle = await open2(
2285
+ located.found,
2286
+ constants3.O_RDONLY | constants3.O_NOFOLLOW | constants3.O_NONBLOCK
2287
+ );
2288
+ const info = await handle.stat();
2289
+ if (!info.isFile()) return null;
2290
+ return await read(handle, info.size);
2291
+ } catch (error2) {
2292
+ if (ABSENT_CODES.has(error2.code ?? "")) return null;
2293
+ throw error2;
2294
+ } finally {
2295
+ await handle?.close();
2296
+ }
2297
+ }
2298
+
2299
+ // src/sessions/agent-reports/task-list.ts
2300
+ import { z as z13 } from "zod";
2301
+ var text = z13.string().refine((value) => value.trim() !== "");
2302
+ var optionalText = text.optional().catch(void 0);
2303
+ var taskStatus = z13.enum(TASK_STATUSES).optional().catch(void 0);
2304
+ var taskId = z13.union([z13.string(), z13.number()]).transform(String);
2305
+ var taskEventSchema = z13.discriminatedUnion("op", [
2306
+ z13.object({
2307
+ op: z13.literal("add"),
2308
+ id: taskId.optional().catch(void 0),
2309
+ subject: text,
2310
+ activeForm: optionalText
2311
+ }),
2312
+ z13.object({
2313
+ op: z13.literal("update"),
2314
+ id: taskId,
2315
+ subject: optionalText,
2316
+ activeForm: optionalText,
2317
+ status: taskStatus
2318
+ }),
2319
+ z13.object({ op: z13.literal("remove"), id: taskId }),
2320
+ z13.object({ op: z13.literal("replace"), tasks: z13.array(z13.unknown()) })
2321
+ ]);
2322
+ var replacementTaskSchema = z13.object({
2323
+ subject: text,
2324
+ activeForm: optionalText,
2325
+ status: taskStatus
2326
+ });
2327
+ function applyEvent(tasks, event) {
2328
+ switch (event.op) {
2329
+ case "replace":
2330
+ return event.tasks.flatMap((candidate) => {
2331
+ const task = replacementTaskSchema.safeParse(candidate);
2332
+ if (!task.success) return [];
2333
+ const { subject, activeForm, status = "pending" } = task.data;
2334
+ return [{ id: null, subject, activeForm, status }];
2335
+ });
2336
+ case "add":
2337
+ return [
2338
+ ...tasks,
2339
+ {
2340
+ id: event.id ?? null,
2341
+ subject: event.subject,
2342
+ activeForm: event.activeForm,
2343
+ status: "pending"
2344
+ }
2345
+ ];
2346
+ case "update":
2347
+ return tasks.map(
2348
+ (task) => task.id === event.id ? {
2349
+ ...task,
2350
+ subject: event.subject ?? task.subject,
2351
+ activeForm: event.activeForm ?? task.activeForm,
2352
+ status: event.status ?? task.status
2353
+ } : task
2354
+ );
2355
+ case "remove":
2356
+ return tasks.filter((task) => task.id !== event.id);
2357
+ }
2358
+ }
2359
+ var cut = (value) => value.slice(0, MAX_TASK_TEXT_CHARS);
2360
+ function replayTasks(log) {
2361
+ let tasks = [];
2362
+ for (const line of log.split("\n")) {
2363
+ if (!line.trim()) continue;
2364
+ let raw;
2365
+ try {
2366
+ raw = JSON.parse(line);
2367
+ } catch {
2368
+ continue;
2369
+ }
2370
+ const event = taskEventSchema.safeParse(raw);
2371
+ if (event.success) tasks = applyEvent(tasks, event.data);
2372
+ }
2373
+ return tasks.slice(0, MAX_PLAN_TASKS).map(({ subject, activeForm, status }) => ({
2374
+ subject: cut(subject),
2375
+ ...activeForm && { activeForm: cut(activeForm) },
2376
+ status
2377
+ }));
2378
+ }
2379
+
2380
+ // src/sessions/agent-reports/readers.ts
2381
+ var MAX_PLAN_BYTES = MAX_PLAN_CHARS * 4;
2382
+ var MAX_TASK_LOG_BYTES = 1024 * 1024;
2383
+ async function readPlanAt(root, relativePath) {
2384
+ const text2 = await readFileAt(root, relativePath, async (handle) => {
2385
+ const buffer = Buffer.alloc(MAX_PLAN_BYTES);
2386
+ const { bytesRead } = await handle.read(buffer, 0, MAX_PLAN_BYTES, 0);
2387
+ return buffer.toString("utf8", 0, bytesRead).trim();
2388
+ });
2389
+ return text2 ? text2.slice(0, MAX_PLAN_CHARS) : null;
2390
+ }
2391
+ function readPlanFile(cwd) {
2392
+ return readPlanAt(cwd, PLAN_FILE_PATH);
2393
+ }
2394
+ function readHookPlan(sessionId, plansDir = HOOK_PLANS_DIR) {
2395
+ return readPlanAt(plansDir, `${sessionId}${PLAN_FILE_SUFFIX}`);
2396
+ }
2397
+ async function readHookTasks(sessionId, plansDir = HOOK_PLANS_DIR) {
2398
+ const log = await readFileAt(
2399
+ plansDir,
2400
+ `${sessionId}${TASKS_FILE_SUFFIX}`,
2401
+ async (handle, size) => {
2402
+ const length = Math.min(size, MAX_TASK_LOG_BYTES);
2403
+ const buffer = Buffer.alloc(length);
2404
+ const { bytesRead } = await handle.read(buffer, 0, length, size - length);
2405
+ const text2 = buffer.toString("utf8", 0, bytesRead);
2406
+ return length < size ? text2.slice(text2.indexOf("\n") + 1) : text2;
2407
+ }
2408
+ );
2409
+ return log === null ? [] : replayTasks(log);
2410
+ }
2411
+
2412
+ // src/sessions/agent-reports/session-report.ts
2413
+ async function readSessionReport(session) {
2414
+ const plan = await readHookPlan(session.id) ?? await readPlanFile(session.cwd) ?? await getSessionPlan(session.tmuxSessionName);
2415
+ return { plan, tasks: await readHookTasks(session.id) };
2416
+ }
2417
+
2294
2418
  // src/security/verify-command.ts
2295
2419
  import { verify as verify3 } from "crypto";
2296
2420
  var ENVELOPE_MAX_AGE_MS = 6e4;
@@ -2427,8 +2551,8 @@ function createOutputPacer(options) {
2427
2551
 
2428
2552
  // src/ws/sanitize-insert-text.ts
2429
2553
  var DISALLOWED_CHARACTERS = /[\x00-\x08\x0b-\x1f\x7f-\x9f‪-‮⁦-⁩]/g;
2430
- function sanitizeInsertText(text) {
2431
- const withLineFeeds = text.replace(/\r\n?/g, "\n");
2554
+ function sanitizeInsertText(text2) {
2555
+ const withLineFeeds = text2.replace(/\r\n?/g, "\n");
2432
2556
  const sanitized = withLineFeeds.replace(DISALLOWED_CHARACTERS, "");
2433
2557
  return { text: sanitized, strippedCount: withLineFeeds.length - sanitized.length };
2434
2558
  }
@@ -2762,14 +2886,14 @@ async function dispatch(conn, message, {
2762
2886
  const session = getSessionOrError(sessionManager, conn, message.sessionId);
2763
2887
  if (!session) return;
2764
2888
  sessionManager.acknowledge(message.sessionId);
2765
- const { text, strippedCount } = sanitizeInsertText(message.text);
2889
+ const { text: text2, strippedCount } = sanitizeInsertText(message.text);
2766
2890
  if (strippedCount > 0) {
2767
2891
  console.warn(`bondsmithd: stripped ${strippedCount} control characters from inserted text`);
2768
2892
  }
2769
2893
  try {
2770
2894
  await sendKeysLiteral(
2771
2895
  session.tmuxSessionName,
2772
- `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`
2896
+ `${BRACKETED_PASTE_START}${text2}${BRACKETED_PASTE_END}`
2773
2897
  );
2774
2898
  } catch (err) {
2775
2899
  conn.send(error(errorMessageOf(err, "failed to insert text")));
@@ -2880,12 +3004,13 @@ async function dispatch(conn, message, {
2880
3004
  const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
2881
3005
  if (!session) return;
2882
3006
  try {
2883
- const plan = await readHookPlan(session.id) ?? await readPlanFile(session.cwd) ?? await getSessionPlan(session.tmuxSessionName);
3007
+ const { plan, tasks } = await readSessionReport(session);
2884
3008
  conn.send({
2885
3009
  type: "workspace.plan",
2886
3010
  sessionId: message.sessionId,
2887
3011
  requestId: message.requestId,
2888
- plan
3012
+ plan,
3013
+ tasks
2889
3014
  });
2890
3015
  } catch (err) {
2891
3016
  conn.send(error(errorMessageOf(err, "failed to read the session plan"), message.requestId));
@@ -3319,9 +3444,9 @@ function isMatchingKeyPair(publicKeyPem, privateKeyPem) {
3319
3444
  return false;
3320
3445
  }
3321
3446
  }
3322
- function extractPemBlock(text, label) {
3447
+ function extractPemBlock(text2, label) {
3323
3448
  const pattern = new RegExp(`-----BEGIN ${label}-----[\\s\\S]*?-----END ${label}-----\\n?`);
3324
- return text.match(pattern)?.[0] ?? null;
3449
+ return text2.match(pattern)?.[0] ?? null;
3325
3450
  }
3326
3451
 
3327
3452
  // src/security/daemon-proof.ts
@@ -3460,8 +3585,8 @@ var PendingAuthorizationStore = class {
3460
3585
  requestToken: randomUUID()
3461
3586
  };
3462
3587
  let settleFn;
3463
- const resultPromise = new Promise((resolve2) => {
3464
- settleFn = resolve2;
3588
+ const resultPromise = new Promise((resolve3) => {
3589
+ settleFn = resolve3;
3465
3590
  });
3466
3591
  const entry = {
3467
3592
  authorization,
@@ -3572,16 +3697,16 @@ var PendingAuthorizationStore = class {
3572
3697
  };
3573
3698
 
3574
3699
  // src/security/revocation-sync.ts
3575
- import { z as z13 } from "zod";
3700
+ import { z as z14 } from "zod";
3576
3701
  var POLL_INTERVAL_MS = 3e4;
3577
3702
  var POLL_JITTER_MS = 6e3;
3578
3703
  var POLL_REQUEST_TIMEOUT_MS = 1e4;
3579
3704
  var BACKOFF_BASE_MS = 2e3;
3580
3705
  var BACKOFF_MAX_MS = 2e4;
3581
3706
  var MAX_CONSECUTIVE_FAILURE_BACKOFFS = 4;
3582
- var checkRevocationStatusResponseSchema = z13.object({
3583
- pairingRevoked: z13.boolean(),
3584
- revokedControllerPublicKeys: z13.array(z13.string())
3707
+ var checkRevocationStatusResponseSchema = z14.object({
3708
+ pairingRevoked: z14.boolean(),
3709
+ revokedControllerPublicKeys: z14.array(z14.string())
3585
3710
  });
3586
3711
  function jitteredInterval(deps) {
3587
3712
  const interval = deps.pollIntervalMs ?? POLL_INTERVAL_MS;
@@ -3931,6 +4056,45 @@ ${new TmuxNotFoundError().message}
3931
4056
  // src/sessions/session-manager.ts
3932
4057
  import { v4 as uuidv4 } from "uuid";
3933
4058
 
4059
+ // src/sessions/agent-reports/lifecycle.ts
4060
+ import { readdir, rm as rm4, stat as stat3 } from "fs/promises";
4061
+ import { basename, join as join9 } from "path";
4062
+ var SWEEP_MIN_AGE_MS = 6e4;
4063
+ async function removeHookPlanFiles(sessionId, plansDir = HOOK_PLANS_DIR) {
4064
+ await Promise.all(
4065
+ [PLAN_FILE_SUFFIX, TASKS_FILE_SUFFIX].map(
4066
+ (suffix) => rm4(join9(plansDir, `${basename(sessionId)}${suffix}`), { force: true })
4067
+ )
4068
+ );
4069
+ }
4070
+ function hookFileSessionId(name) {
4071
+ for (const suffix of [PLAN_FILE_SUFFIX, TASKS_FILE_SUFFIX]) {
4072
+ const sessionId = name.endsWith(suffix) ? name.slice(0, -suffix.length) : "";
4073
+ if (sessionId && !sessionId.includes(".")) return sessionId;
4074
+ }
4075
+ return null;
4076
+ }
4077
+ async function sweepOrphanedHookPlanFiles(liveSessionIds, plansDir = HOOK_PLANS_DIR) {
4078
+ let names;
4079
+ try {
4080
+ names = await readdir(plansDir);
4081
+ } catch (error2) {
4082
+ if (ABSENT_CODES.has(error2.code ?? "")) return;
4083
+ throw error2;
4084
+ }
4085
+ const now = Date.now();
4086
+ await Promise.all(
4087
+ names.map(async (name) => {
4088
+ const sessionId = hookFileSessionId(name);
4089
+ if (sessionId === null || liveSessionIds.has(sessionId)) return;
4090
+ const path = join9(plansDir, name);
4091
+ const modifiedMs = await stat3(path).then((info) => info.mtimeMs).catch(() => null);
4092
+ if (modifiedMs === null || now - modifiedMs < SWEEP_MIN_AGE_MS) return;
4093
+ await rm4(path, { force: true });
4094
+ })
4095
+ );
4096
+ }
4097
+
3934
4098
  // src/sessions/git/worktree.ts
3935
4099
  import { execFile as execFileCb3 } from "child_process";
3936
4100
  import { promisify as promisify3 } from "util";
@@ -4029,22 +4193,15 @@ function decodeSkippedFiles(raw) {
4029
4193
 
4030
4194
  // src/sessions/tmux/agent-state.ts
4031
4195
  var AGENT_STATE_OPTION = "@bondsmith-agent-state";
4032
- var HEADLINE_OPTION = "@bondsmith-headline";
4033
4196
  var PULL_REQUESTS_OPTION = "@bondsmith-prs";
4034
4197
  var PULL_REQUEST_URL = /https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+/g;
4035
- var MAX_HEADLINE_CHARS = 200;
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}}`;
4198
+ var LIVE_SESSION_REPORTS_FORMAT = `#{session_name} #{${AGENT_STATE_OPTION}} #{window_bell_flag} #{${SETUP_FAILED_OPTION}} #{${SETUP_SKIPPED_OPTION}} #{${PULL_REQUESTS_OPTION}}`;
4037
4199
  var AGENT_STATE_OUTPUT_GRACE_MS = 2e3;
4038
4200
  var FUTURE_REPORT_ALLOWANCE_MS = 2e3;
4039
4201
  var WORKING_SILENCE_MS = 12e4;
4040
4202
  var SETTLE_QUIET_MS = 8e3;
4041
4203
  var ACKNOWLEDGE_GRACE_MS = 3e3;
4042
4204
  var SETTLED_STATES = /* @__PURE__ */ new Set(["waiting", "done"]);
4043
- function cleanHeadline(raw) {
4044
- const line = raw.replace(/\s+/g, " ").trim();
4045
- if (!line) return null;
4046
- return line.length > MAX_HEADLINE_CHARS ? `${line.slice(0, MAX_HEADLINE_CHARS - 1)}\u2026` : line;
4047
- }
4048
4205
  function parsePullRequestUrls(raw) {
4049
4206
  const urls = [...new Set(raw.match(PULL_REQUEST_URL) ?? [])];
4050
4207
  return urls.slice(-MAX_SESSION_PULL_REQUESTS);
@@ -4061,8 +4218,7 @@ function parseLiveSessionReports(output) {
4061
4218
  bell = "",
4062
4219
  setupFailed = "",
4063
4220
  setupSkipped = "",
4064
- pullRequests = "",
4065
- ...headline
4221
+ pullRequests = ""
4066
4222
  ] = line.split(" ");
4067
4223
  if (sessionName) {
4068
4224
  reports.set(sessionName, {
@@ -4070,8 +4226,7 @@ function parseLiveSessionReports(output) {
4070
4226
  bell: bell === "1",
4071
4227
  setupFailed: setupFailed === "1",
4072
4228
  setupSkipped: decodeSkippedFiles(setupSkipped),
4073
- pullRequestUrls: parsePullRequestUrls(pullRequests),
4074
- headline: cleanHeadline(headline.join(" "))
4229
+ pullRequestUrls: parsePullRequestUrls(pullRequests)
4075
4230
  });
4076
4231
  }
4077
4232
  }
@@ -4122,56 +4277,67 @@ function resolveSessionAttention(report, lastActivityAt, { nowMs, acknowledgedAt
4122
4277
 
4123
4278
  // src/sessions/tmux/create.ts
4124
4279
  import { access } from "fs/promises";
4125
- import { homedir as homedir7 } from "os";
4126
- import { join as join11 } from "path";
4127
4280
 
4128
- // src/sessions/agents/claude-hooks.ts
4281
+ // src/sessions/agents/adapter.ts
4282
+ var ENV_ASSIGNMENT_WORD = String.raw`\w+=\S*`;
4283
+ var ENV_ASSIGNMENT = new RegExp(`^${ENV_ASSIGNMENT_WORD}$`);
4284
+ function leadingBinary(command) {
4285
+ return command.split(/\s+/).filter(Boolean).find((word) => !ENV_ASSIGNMENT.test(word));
4286
+ }
4287
+ function plainAdapter(id) {
4288
+ return {
4289
+ id,
4290
+ matches: (command) => leadingBinary(command) === id,
4291
+ prepareCommand: async (command) => command
4292
+ };
4293
+ }
4294
+
4295
+ // src/sessions/agents/claude/hooks.ts
4129
4296
  import { execFile as execFile4 } from "child_process";
4130
4297
  import { randomUUID as randomUUID2 } from "crypto";
4131
- import { mkdir as mkdir4, rename as rename4, rm as rm4, writeFile as writeFile4 } from "fs/promises";
4298
+ import { mkdir as mkdir4, rename as rename4, rm as rm5, writeFile as writeFile4 } from "fs/promises";
4132
4299
  import { homedir as homedir6 } from "os";
4133
- import { join as join9 } from "path";
4300
+ import { join as join10 } from "path";
4134
4301
  import { promisify as promisify4 } from "util";
4135
4302
 
4136
4303
  // src/sessions/session-env.ts
4137
4304
  var SESSION_ENV_VAR = "BONDSMITH_SESSION";
4138
4305
 
4139
- // src/sessions/agents/claude-hook-scripts.ts
4140
- var REPLY_ONLY_PROMPT = /^(y|yes|yep|yeah|no|nope|ok|okay|k|sure|go|go on|go ahead|continue|proceed|thanks|thank you|thx|done|great|nice|cool|lgtm)[.!\s]*$/i;
4141
- var MIN_HEADLINE_CHARS = 4;
4142
- function planHookScript(plansDir) {
4306
+ // src/sessions/agent-reports/hook-scripts.ts
4307
+ function storePlanScript(plansDir, planExpr) {
4143
4308
  return [
4144
4309
  `let d="";`,
4145
4310
  `process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
4146
- `const p=JSON.parse(d).tool_response?.plan;if(!p)return;`,
4311
+ `const j=JSON.parse(d),p=${planExpr};if(typeof p!=="string"||!p)return;`,
4147
4312
  `const fs=require("fs"),path=require("path"),dir=${JSON.stringify(plansDir)};`,
4148
4313
  // basename keeps an odd session value from naming a file outside the plans folder.
4149
- `const name=path.basename(process.env.${SESSION_ENV_VAR})+".md",tmp=path.join(dir,"."+name+"."+process.pid);`,
4314
+ `const name=path.basename(process.env.${SESSION_ENV_VAR})+${JSON.stringify(PLAN_FILE_SUFFIX)},tmp=path.join(dir,"."+name+"."+process.pid);`,
4150
4315
  // Written aside and renamed: a reader never sees half a plan, and a symlink in the way is replaced, not followed.
4151
4316
  `fs.mkdirSync(dir,{recursive:true,mode:0o700});`,
4152
4317
  `fs.writeFileSync(tmp,p,{mode:0o600});fs.renameSync(tmp,path.join(dir,name))`,
4153
4318
  `}catch{}})`
4154
4319
  ].join("");
4155
4320
  }
4156
- function headlineHookScript() {
4321
+ function appendTaskEventScript(plansDir, eventExpr) {
4157
4322
  return [
4158
4323
  `let d="";`,
4159
4324
  `process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
4160
- `const p=JSON.parse(d).prompt;if(typeof p!=="string")return;`,
4161
- `const t=p.replace(/\\s+/g," ").trim();`,
4162
- `if(t.length<${MIN_HEADLINE_CHARS}||${REPLY_ONLY_PROMPT.toString()}.test(t))return;`,
4163
- `const h=t.length>${MAX_HEADLINE_CHARS}?t.slice(0,${MAX_HEADLINE_CHARS - 1})+"\\u2026":t;`,
4164
- // execFileSync takes the text as an argument, so the prompt is never parsed by a shell.
4165
- `require("child_process").execFileSync("tmux",["set-option","-t",process.env.TMUX_PANE,"-q","${HEADLINE_OPTION}",h])`,
4325
+ `const j=JSON.parse(d),e=${eventExpr};if(!e)return;`,
4326
+ `const fs=require("fs"),path=require("path"),dir=${JSON.stringify(plansDir)},c=fs.constants;`,
4327
+ `fs.mkdirSync(dir,{recursive:true,mode:0o700});`,
4328
+ // O_NOFOLLOW so a symlink in the way is refused rather than written through.
4329
+ `const fd=fs.openSync(path.join(dir,path.basename(process.env.${SESSION_ENV_VAR})+${JSON.stringify(TASKS_FILE_SUFFIX)}),c.O_WRONLY|c.O_APPEND|c.O_CREAT|c.O_NOFOLLOW,0o600);`,
4330
+ `try{fs.writeSync(fd,JSON.stringify(e)+"\\n")}finally{fs.closeSync(fd)}`,
4166
4331
  `}catch{}})`
4167
4332
  ].join("");
4168
4333
  }
4334
+ var PR_CREATE_MENTION = "gh pr create";
4169
4335
  var PR_CREATE_COMMAND = /(?:^|[;&|\n(])\s*(?:\w+=\S*\s+)*gh\s+pr\s+create\b/;
4170
- function pullRequestHookScript() {
4336
+ function recordPullRequestScript(commandExpr, outputExpr) {
4171
4337
  return [
4172
4338
  `let d="";`,
4173
4339
  `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;`,
4340
+ `const j=JSON.parse(d),c=${commandExpr},o=${outputExpr};`,
4175
4341
  `if(typeof c!=="string"||typeof o!=="string"||!${PR_CREATE_COMMAND.toString()}.test(c))return;`,
4176
4342
  `const m=o.match(${PULL_REQUEST_URL.toString()});if(!m)return;const u=m[m.length-1];`,
4177
4343
  `const cp=require("child_process"),p=process.env.TMUX_PANE;`,
@@ -4182,29 +4348,49 @@ function pullRequestHookScript() {
4182
4348
  ].join("");
4183
4349
  }
4184
4350
 
4185
- // src/sessions/agents/claude-settings.ts
4351
+ // src/sessions/agent-reports/hook-commands.ts
4186
4352
  function shellQuote3(value) {
4187
4353
  return `'${value.replaceAll("'", `'\\''`)}'`;
4188
4354
  }
4189
4355
  function nodeHookCommand(script, nodePath) {
4190
4356
  return `[ -n "$${SESSION_ENV_VAR}" ] && ${shellQuote3(nodePath)} -e ${shellQuote3(script)}; exit 0`;
4191
4357
  }
4358
+ function recordPullRequestCommand(script, nodePath) {
4359
+ const run = `${shellQuote3(nodePath)} -e ${shellQuote3(script)}`;
4360
+ return `[ -n "$${SESSION_ENV_VAR}" ] && { input=$(cat); case "$input" in *"${PR_CREATE_MENTION}"*) printf '%s' "$input" | ${run};; esac; }; exit 0`;
4361
+ }
4362
+ function stateHookCommand(state) {
4363
+ return `[ -n "$${SESSION_ENV_VAR}" ] && tmux set-option -t "$TMUX_PANE" -q ${AGENT_STATE_OPTION} "${state}:$(date +%s)"; exit 0`;
4364
+ }
4365
+
4366
+ // src/sessions/agents/claude/scripts.ts
4367
+ var PLAN = `j.tool_response?.plan??j.tool_input?.plan`;
4368
+ var TASK_EVENT_BY_TOOL = {
4369
+ TaskCreate: `{op:"add",id:j.tool_response?.task?.id,subject:i.subject,activeForm:i.activeForm}`,
4370
+ TaskUpdate: `i.status==="deleted"?{op:"remove",id:i.taskId}:{op:"update",id:i.taskId,subject:i.subject,status:i.status,activeForm:i.activeForm}`,
4371
+ TodoWrite: `Array.isArray(i.todos)?{op:"replace",tasks:i.todos.map(t=>({subject:t?.content,status:t?.status,activeForm:t?.activeForm}))}:undefined`
4372
+ };
4373
+ var TASK_TOOLS = Object.keys(TASK_EVENT_BY_TOOL);
4374
+ var TASK_EVENT = `(()=>{const i=j.tool_input||{};switch(j.tool_name){${Object.entries(
4375
+ TASK_EVENT_BY_TOOL
4376
+ ).map(([tool, event]) => `case${JSON.stringify(tool)}:return ${event};`).join("")}}})()`;
4377
+ var planHookScript = (plansDir) => storePlanScript(plansDir, PLAN);
4378
+ var taskHookScript = (plansDir) => appendTaskEventScript(plansDir, TASK_EVENT);
4379
+ var pullRequestHookScript = () => recordPullRequestScript(`j.tool_input?.command`, `j.tool_response?.stdout`);
4380
+
4381
+ // src/sessions/agents/claude/settings.ts
4192
4382
  function planHookCommand(nodePath = process.execPath, plansDir = HOOK_PLANS_DIR) {
4193
4383
  return nodeHookCommand(planHookScript(plansDir), nodePath);
4194
4384
  }
4195
- function headlineHookCommand(nodePath = process.execPath) {
4196
- return nodeHookCommand(headlineHookScript(), nodePath);
4385
+ function taskHookCommand(nodePath = process.execPath, plansDir = HOOK_PLANS_DIR) {
4386
+ return nodeHookCommand(taskHookScript(plansDir), nodePath);
4197
4387
  }
4198
4388
  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
- }
4202
- function stateHookCommand(state) {
4203
- return `[ -n "$${SESSION_ENV_VAR}" ] && tmux set-option -t "$TMUX_PANE" -q ${AGENT_STATE_OPTION} "${state}:$(date +%s)"; exit 0`;
4389
+ return recordPullRequestCommand(pullRequestHookScript(), nodePath);
4204
4390
  }
4205
4391
  var STATE_HOOK_TIMEOUT_SECONDS = 5;
4206
4392
  var PLAN_HOOK_TIMEOUT_SECONDS = 10;
4207
- var HEADLINE_HOOK_TIMEOUT_SECONDS = 5;
4393
+ var TASK_HOOK_TIMEOUT_SECONDS = 10;
4208
4394
  var PULL_REQUEST_HOOK_TIMEOUT_SECONDS = 5;
4209
4395
  function claudeSettings() {
4210
4396
  const run = (command, timeout) => ({ type: "command", command, timeout });
@@ -4216,38 +4402,43 @@ function claudeSettings() {
4216
4402
  matcher: "ExitPlanMode",
4217
4403
  hooks: [run(planHookCommand(), PLAN_HOOK_TIMEOUT_SECONDS)]
4218
4404
  },
4405
+ {
4406
+ matcher: TASK_TOOLS.join("|"),
4407
+ hooks: [run(taskHookCommand(), TASK_HOOK_TIMEOUT_SECONDS)]
4408
+ },
4219
4409
  {
4220
4410
  matcher: "Bash",
4221
4411
  hooks: [run(pullRequestHookCommand(), PULL_REQUEST_HOOK_TIMEOUT_SECONDS)]
4222
4412
  }
4223
4413
  ],
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.
4225
- UserPromptSubmit: [
4226
- {
4227
- hooks: [report("working"), run(headlineHookCommand(), HEADLINE_HOOK_TIMEOUT_SECONDS)]
4228
- }
4414
+ // Working from the moment a prompt is sent; waiting when it needs the user; done when a turn ends.
4415
+ UserPromptSubmit: [{ hooks: [report("working")] }],
4416
+ // The plan is stored while it waits for approval, so it shows before the user decides.
4417
+ PermissionRequest: [
4418
+ { matcher: "*", hooks: [report("waiting")] },
4419
+ { matcher: "ExitPlanMode", hooks: [run(planHookCommand(), PLAN_HOOK_TIMEOUT_SECONDS)] }
4229
4420
  ],
4230
- PermissionRequest: [{ matcher: "*", hooks: [report("waiting")] }],
4231
4421
  Stop: [{ hooks: [report("done")] }],
4232
4422
  StopFailure: [{ hooks: [report("waiting")] }]
4233
4423
  }
4234
4424
  };
4235
4425
  }
4236
4426
 
4237
- // src/sessions/agents/claude-hooks.ts
4427
+ // src/sessions/agents/claude/hooks.ts
4238
4428
  var execFileAsync = promisify4(execFile4);
4239
- var CLAUDE_HOOKS_DIR = join9(homedir6(), ".bondsmith", "hooks");
4429
+ var CLAUDE_HOOKS_DIR = join10(homedir6(), ".bondsmith", "hooks");
4240
4430
  var AGENT_HOOKS_ENV_VAR = "BONDSMITH_AGENT_HOOKS";
4241
4431
  var HOOKS_OFF_VALUES = /^(off|0|false|no)$/i;
4242
4432
  var CLAUDE_BINARY = "claude";
4243
- var LEADING_CLAUDE = new RegExp(`^(\\s*)${CLAUDE_BINARY}(?=\\s|$)`);
4433
+ var LEADING_CLAUDE = new RegExp(
4434
+ `^(\\s*(?:${ENV_ASSIGNMENT_WORD}\\s+)*)${CLAUDE_BINARY}(?=\\s|$)`
4435
+ );
4244
4436
  var SETTINGS_FLAG = "--settings";
4245
4437
  var SUPPORT_PROBE_TIMEOUT_MS = 1e4;
4246
4438
  var SUPPORT_RETRY_AFTER_MS = 6e4;
4247
4439
  function isPlainClaudeCommand(command) {
4248
- const words = command.trim().split(/\s+/);
4249
- if (words[0] !== CLAUDE_BINARY) return false;
4250
- return !words.some((word) => word === SETTINGS_FLAG || word.startsWith(`${SETTINGS_FLAG}=`));
4440
+ if (leadingBinary(command) !== CLAUDE_BINARY) return false;
4441
+ return !command.trim().split(/\s+/).some((word) => word === SETTINGS_FLAG || word.startsWith(`${SETTINGS_FLAG}=`));
4251
4442
  }
4252
4443
  async function probeSettingsSupport() {
4253
4444
  try {
@@ -4288,14 +4479,14 @@ async function prepareAgentCommand(command, {
4288
4479
  if (HOOKS_OFF_VALUES.test(env[AGENT_HOOKS_ENV_VAR] ?? "")) return command;
4289
4480
  if (!isPlainClaudeCommand(command)) return command;
4290
4481
  if (!await supportsSettings()) return command;
4291
- const file = join9(hooksDir, "claude-settings.json");
4482
+ const file = join10(hooksDir, "claude-settings.json");
4292
4483
  const staging = `${file}.${randomUUID2()}.tmp`;
4293
4484
  try {
4294
4485
  await mkdir4(hooksDir, { recursive: true });
4295
4486
  await writeFile4(staging, JSON.stringify(claudeSettings(), null, " "));
4296
4487
  await rename4(staging, file);
4297
4488
  } catch (error2) {
4298
- await rm4(staging, { force: true }).catch(() => void 0);
4489
+ await rm5(staging, { force: true }).catch(() => void 0);
4299
4490
  console.error(
4300
4491
  "could not write the Claude Code hook settings; starting claude without them",
4301
4492
  error2
@@ -4308,6 +4499,29 @@ async function prepareAgentCommand(command, {
4308
4499
  );
4309
4500
  }
4310
4501
 
4502
+ // src/sessions/agents/claude/adapter.ts
4503
+ var claudeAdapter = {
4504
+ id: "claude",
4505
+ matches: (command) => leadingBinary(command) === "claude",
4506
+ prepareCommand: (command) => prepareAgentCommand(command)
4507
+ };
4508
+
4509
+ // src/sessions/agents/registry.ts
4510
+ var ADAPTERS = {
4511
+ claude: claudeAdapter,
4512
+ codex: plainAdapter("codex"),
4513
+ opencode: plainAdapter("opencode")
4514
+ };
4515
+ function adapterFor(command) {
4516
+ return Object.values(ADAPTERS).find((adapter) => adapter.matches(command));
4517
+ }
4518
+ function agentIdFor(command) {
4519
+ return adapterFor(command)?.id ?? null;
4520
+ }
4521
+ async function prepareCommand(command) {
4522
+ return await adapterFor(command)?.prepareCommand(command) ?? command;
4523
+ }
4524
+
4311
4525
  // src/sessions/git/start-point.ts
4312
4526
  var WorktreeBaseBranchNotFoundError = class extends Error {
4313
4527
  constructor(branch) {
@@ -4331,15 +4545,26 @@ async function resolveWorktreeStartPoint(sourceCwd, baseBranch) {
4331
4545
  throw new WorktreeBaseBranchNotFoundError(baseBranch);
4332
4546
  }
4333
4547
 
4548
+ // src/sessions/worktree-root.ts
4549
+ import { lstat as lstat3, realpath as realpath2 } from "fs/promises";
4550
+ import { homedir as homedir7 } from "os";
4551
+ import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute3, join as join11, resolve as resolve2 } from "path";
4552
+ function worktreeRoot() {
4553
+ return join11(homedir7(), ".bondsmith", "worktrees");
4554
+ }
4555
+ function worktreePathFor(sessionId) {
4556
+ return join11(worktreeRoot(), sessionId);
4557
+ }
4558
+
4334
4559
  // src/sessions/worktree-setup/copy-files.ts
4335
4560
  import { constants as constants4 } from "fs";
4336
- import { copyFile, glob, lstat as lstat3, mkdir as mkdir5, realpath as realpath2 } from "fs/promises";
4337
- import { dirname as dirname4, isAbsolute as isAbsolute3, join as join10, relative, sep as sep2 } from "path";
4561
+ import { copyFile, glob, lstat as lstat4, mkdir as mkdir5, realpath as realpath3 } from "fs/promises";
4562
+ import { dirname as dirname5, isAbsolute as isAbsolute4, join as join12, relative, sep as sep2 } from "path";
4338
4563
  var GIT_EXIT_NONE_IGNORED = 1;
4339
4564
  var TRACKED_CHECK_BATCH = 200;
4340
4565
  function isInside(root, path) {
4341
4566
  const rel = relative(root, path);
4342
- return rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute3(rel);
4567
+ return rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute4(rel);
4343
4568
  }
4344
4569
  async function ignoredPaths(worktree, paths) {
4345
4570
  if (paths.length === 0) return /* @__PURE__ */ new Set();
@@ -4368,13 +4593,13 @@ async function trackedPaths(worktree, paths) {
4368
4593
  }
4369
4594
  return tracked;
4370
4595
  }
4371
- async function copyFilesIntoWorktree(sourceRoot, worktreeRoot, globs) {
4596
+ async function copyFilesIntoWorktree(sourceRoot, worktreeRoot2, globs) {
4372
4597
  const result = { copied: [], failed: 0, skipped: [] };
4373
4598
  const patterns = globs.filter(isSafeCopyGlob);
4374
4599
  if (patterns.length === 0) return result;
4375
4600
  try {
4376
- const source = await realpath2(sourceRoot);
4377
- const worktree = await realpath2(worktreeRoot);
4601
+ const source = await realpath3(sourceRoot);
4602
+ const worktree = await realpath3(worktreeRoot2);
4378
4603
  const candidates = [];
4379
4604
  for await (const match of glob(patterns, { cwd: source })) {
4380
4605
  if (match.split(sep2).includes(".git")) continue;
@@ -4417,11 +4642,11 @@ async function copyFilesIntoWorktree(sourceRoot, worktreeRoot, globs) {
4417
4642
  return result;
4418
4643
  }
4419
4644
  async function isCopyCandidate(source, worktree, match) {
4420
- const from = join10(source, match);
4421
- if (!(await lstat3(from)).isFile()) return false;
4422
- if (!isInside(source, await realpath2(from))) return false;
4645
+ const from = join12(source, match);
4646
+ if (!(await lstat4(from)).isFile()) return false;
4647
+ if (!isInside(source, await realpath3(from))) return false;
4423
4648
  try {
4424
- await lstat3(join10(worktree, match));
4649
+ await lstat4(join12(worktree, match));
4425
4650
  return false;
4426
4651
  } catch (error2) {
4427
4652
  if (error2.code === "ENOENT") return true;
@@ -4429,11 +4654,11 @@ async function isCopyCandidate(source, worktree, match) {
4429
4654
  }
4430
4655
  }
4431
4656
  async function copyOne(source, worktree, match) {
4432
- const to = join10(worktree, match);
4433
- await mkdir5(dirname4(to), { recursive: true });
4434
- if (!isInside(worktree, await realpath2(dirname4(to)))) return false;
4657
+ const to = join12(worktree, match);
4658
+ await mkdir5(dirname5(to), { recursive: true });
4659
+ if (!isInside(worktree, await realpath3(dirname5(to)))) return false;
4435
4660
  try {
4436
- await copyFile(join10(source, match), to, constants4.COPYFILE_EXCL);
4661
+ await copyFile(join12(source, match), to, constants4.COPYFILE_EXCL);
4437
4662
  } catch (error2) {
4438
4663
  if (error2.code === "EEXIST") return false;
4439
4664
  throw error2;
@@ -4448,9 +4673,6 @@ var InvalidCwdError = class extends Error {
4448
4673
  this.name = "InvalidCwdError";
4449
4674
  }
4450
4675
  };
4451
- function worktreePathFor(sessionId) {
4452
- return join11(homedir7(), ".bondsmith", "worktrees", sessionId);
4453
- }
4454
4676
  async function createTmuxSession(input) {
4455
4677
  const sourceCwd = expandHome(input.cwd);
4456
4678
  let cwd = sourceCwd;
@@ -4481,7 +4703,7 @@ async function createTmuxSession(input) {
4481
4703
  const tmuxSessionName = tmuxSessionNameFor(input.id);
4482
4704
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
4483
4705
  const baseline = await captureBaseline(cwd, { freshCheckout: worktreeOf !== null });
4484
- const agentCommand = await prepareAgentCommand(input.command);
4706
+ const agentCommand = await prepareCommand(input.command);
4485
4707
  const setupCommand = worktreeOf ? input.worktreeSetup?.setupCommand?.trim() : void 0;
4486
4708
  const launchCommand = setupCommand ? wrapCommandWithSetup({ setupCommand, command: agentCommand, tmuxSessionName }) : agentCommand;
4487
4709
  await runTmux(
@@ -4530,6 +4752,7 @@ async function createTmuxSession(input) {
4530
4752
  id: input.id,
4531
4753
  title: input.title,
4532
4754
  command: input.command,
4755
+ agent: agentIdFor(input.command),
4533
4756
  cwd,
4534
4757
  tmuxSessionName,
4535
4758
  status: "running",
@@ -4566,6 +4789,7 @@ function parseTmuxSessions(output) {
4566
4789
  id,
4567
4790
  title,
4568
4791
  command,
4792
+ agent: agentIdFor(command),
4569
4793
  cwd,
4570
4794
  tmuxSessionName: sessionName,
4571
4795
  status: "running",
@@ -4649,6 +4873,9 @@ var SessionManager = class {
4649
4873
  for (const [id, baseline] of discovered.baselines) {
4650
4874
  this.baselines.set(id, baseline);
4651
4875
  }
4876
+ sweepOrphanedHookPlanFiles(new Set(this.sessions.keys())).catch(
4877
+ (error2) => console.error("Failed to sweep stored plans of ended sessions", error2)
4878
+ );
4652
4879
  }
4653
4880
  list() {
4654
4881
  return Array.from(this.sessions.values());
@@ -4744,7 +4971,6 @@ ${error2.message}
4744
4971
  agentState: resolveSessionAttention(report, enriched.lastActivityAt, {
4745
4972
  acknowledgedAtMs: this.acknowledgements.get(session.id) ?? null
4746
4973
  }),
4747
- headline: report?.headline ?? null,
4748
4974
  ...report?.pullRequestUrls?.length && { pullRequestUrls: report.pullRequestUrls },
4749
4975
  ...report?.setupFailed && { worktreeSetupFailed: true },
4750
4976
  ...report?.setupSkipped?.length && { worktreeSetupSkipped: report.setupSkipped }
@@ -4767,12 +4993,15 @@ ${error2.message}
4767
4993
  this.sessions.delete(session.id);
4768
4994
  this.baselines.delete(session.id);
4769
4995
  this.acknowledgements.delete(session.id);
4996
+ removeHookPlanFiles(session.id).catch(
4997
+ (error2) => console.error("Failed to remove the session's stored plan", { sessionId: session.id, error: error2 })
4998
+ );
4770
4999
  this.emitUpdated(updated);
4771
5000
  return updated;
4772
5001
  }
4773
5002
  };
4774
5003
  function snapshotEquals(a, b) {
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 ?? []);
5004
+ return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt && (a.agentState ?? null) === (b.agentState ?? null) && JSON.stringify(a.pullRequestUrls ?? []) === JSON.stringify(b.pullRequestUrls ?? []) && (a.worktreeSetupFailed ?? false) === (b.worktreeSetupFailed ?? false) && JSON.stringify(a.worktreeSetupSkipped ?? []) === JSON.stringify(b.worktreeSetupSkipped ?? []);
4776
5005
  }
4777
5006
 
4778
5007
  // src/ws/server.ts
@@ -4939,10 +5168,10 @@ if (isVersionRequest(process.argv.slice(2))) {
4939
5168
  process.exit(0);
4940
5169
  }
4941
5170
  function loadRepoRootEnv() {
4942
- const repoRoot = join12(dirname5(fileURLToPath(import.meta.url)), "..", "..", "..");
4943
- if (!existsSync(join12(repoRoot, "pnpm-workspace.yaml"))) return;
4944
- const envLocal = join12(repoRoot, ".env.local");
4945
- const envDefault = join12(repoRoot, ".env");
5171
+ const repoRoot = join13(dirname6(fileURLToPath(import.meta.url)), "..", "..", "..");
5172
+ if (!existsSync(join13(repoRoot, "pnpm-workspace.yaml"))) return;
5173
+ const envLocal = join13(repoRoot, ".env.local");
5174
+ const envDefault = join13(repoRoot, ".env");
4946
5175
  loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
4947
5176
  }
4948
5177
  loadRepoRootEnv();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bondsmith/bondsmithd",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
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",