@bondsmith/bondsmithd 0.1.15 → 0.1.17

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 +839 -301
  3. package/package.json +2 -1
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 = 21;
22
24
 
23
25
  // ../../packages/local-agent-protocol/src/branch-name.ts
24
26
  var MAX_BRANCH_NAME_LENGTH = 250;
@@ -46,6 +48,19 @@ var PROJECT_PATH_INVALID_REASONS = [
46
48
  "permission_denied"
47
49
  ];
48
50
 
51
+ // ../../packages/local-agent-protocol/src/project-worktree.ts
52
+ var WORKTREE_REMOVE_REFUSALS = [
53
+ "not_found",
54
+ "locked",
55
+ "session_active",
56
+ "has_changes"
57
+ ];
58
+ var WORKTREE_BRANCH_OUTCOMES = ["deleted", "kept", "not_merged", "failed"];
59
+ var MAX_PROJECT_WORKTREES = 100;
60
+ var MAX_WORKTREE_PATH_LENGTH = 1024;
61
+ var MAX_WORKTREE_BRANCH_LENGTH = 255;
62
+ var MAX_WORKTREE_HEAD_LENGTH = 64;
63
+
49
64
  // ../../packages/local-agent-protocol/src/schemas.ts
50
65
  import { z as z3 } from "zod";
51
66
 
@@ -185,6 +200,8 @@ var MAX_WIRE_MESSAGE_BYTES = 256 * 1024;
185
200
  var MAX_DAEMON_REPLY_BYTES = 200 * 1024;
186
201
  var MAX_RANGE_LINES = 500;
187
202
  var MAX_PLAN_CHARS = 32 * 1024;
203
+ var MAX_PLAN_TASKS = 50;
204
+ var MAX_TASK_TEXT_CHARS = 120;
188
205
  var MAX_TERMINAL_DIMENSION = 1e3;
189
206
  var terminalSizeSchema = z3.object({
190
207
  cols: z3.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
@@ -205,9 +222,9 @@ var localSessionSchema = z3.object({
205
222
  worktreeOf: z3.string().nullable(),
206
223
  // .catch(undefined): a state this version doesn't know must never drop the whole session message.
207
224
  agentState: z3.enum(AGENT_STATES).nullable().optional().catch(void 0),
225
+ agent: z3.enum(AGENT_IDS).nullable().optional().catch(void 0),
208
226
  worktreeSetupFailed: z3.boolean().optional().catch(void 0),
209
227
  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
228
  pullRequestUrls: z3.array(z3.string().max(300)).max(MAX_SESSION_PULL_REQUESTS).optional().catch(void 0)
212
229
  });
213
230
  var workspaceFileEntrySchema = z3.object({
@@ -260,6 +277,36 @@ var projectPathValidationSchema = z3.discriminatedUnion("valid", [
260
277
  z3.object({ valid: z3.literal(false), reason: z3.enum(PROJECT_PATH_INVALID_REASONS) })
261
278
  ]);
262
279
  var noNul = (value) => !value.includes("\0");
280
+ var projectWorktreeSchema = z3.object({
281
+ path: z3.string().max(MAX_WORKTREE_PATH_LENGTH),
282
+ branch: z3.string().max(MAX_WORKTREE_BRANCH_LENGTH).nullable(),
283
+ head: z3.string().max(MAX_WORKTREE_HEAD_LENGTH),
284
+ prunable: z3.boolean(),
285
+ locked: z3.boolean(),
286
+ dirty: z3.boolean(),
287
+ sessionId: z3.string().nullable()
288
+ });
289
+ var worktreeRemoveResultSchema = z3.discriminatedUnion("removed", [
290
+ z3.object({ removed: z3.literal(true), branch: z3.enum(WORKTREE_BRANCH_OUTCOMES) }),
291
+ z3.object({ removed: z3.literal(false), reason: z3.enum(WORKTREE_REMOVE_REFUSALS) })
292
+ ]);
293
+ var WORKTREE_LIST_REPLY_OVERHEAD_BYTES = 4096;
294
+ var utf8Bytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
295
+ function fitsWorktreeLimits(row) {
296
+ return row.path.length <= MAX_WORKTREE_PATH_LENGTH && (row.branch === null || row.branch.length <= MAX_WORKTREE_BRANCH_LENGTH) && row.head.length <= MAX_WORKTREE_HEAD_LENGTH;
297
+ }
298
+ function fitWorktreeReply(worktrees) {
299
+ const kept = [];
300
+ let bytes = WORKTREE_LIST_REPLY_OVERHEAD_BYTES;
301
+ for (const row of worktrees) {
302
+ if (!fitsWorktreeLimits(row)) continue;
303
+ const rowBytes = utf8Bytes(row) + 1;
304
+ if (kept.length >= MAX_PROJECT_WORKTREES || bytes + rowBytes > MAX_DAEMON_REPLY_BYTES) break;
305
+ kept.push(row);
306
+ bytes += rowBytes;
307
+ }
308
+ return { worktrees: kept, truncated: kept.length < worktrees.length };
309
+ }
263
310
  var worktreeSetupSchema = z3.object({
264
311
  copyGlobs: z3.array(z3.string().min(1).max(MAX_COPY_GLOB_LENGTH).refine(noNul).refine(isSafeCopyGlob)).max(MAX_COPY_GLOBS),
265
312
  setupCommand: z3.string().max(MAX_SETUP_COMMAND_LENGTH).refine(noNul).optional()
@@ -356,6 +403,19 @@ var browserMessageSchema = z3.discriminatedUnion("type", [
356
403
  path: z3.string().min(1).max(4096),
357
404
  requestId: z3.string().min(1).max(200)
358
405
  }),
406
+ z3.object({
407
+ type: z3.literal("worktree.list"),
408
+ repoPath: z3.string().min(1).max(4096).refine(noNul),
409
+ requestId: z3.string().min(1).max(200)
410
+ }),
411
+ z3.object({
412
+ type: z3.literal("worktree.remove"),
413
+ repoPath: z3.string().min(1).max(4096).refine(noNul),
414
+ worktreePath: z3.string().min(1).max(4096).refine(noNul),
415
+ force: z3.boolean(),
416
+ deleteBranch: z3.boolean(),
417
+ requestId: z3.string().min(1).max(200)
418
+ }),
359
419
  z3.object({ type: z3.literal("daemon.getInfo") }),
360
420
  // Second-controller enrollment (ADR-011/ADR-012, BD-433) — deliberately
361
421
  // unsigned: the requesting browser has no trusted key yet to sign with.
@@ -388,6 +448,11 @@ var connectionAuthenticationFailureReasonSchema = z3.enum([
388
448
  "key_agreement_failed"
389
449
  ]);
390
450
  var connectionRejectionReasonSchema = z3.enum(["origin_not_allowed", "token_invalid"]);
451
+ var sessionTaskSchema = z3.object({
452
+ subject: z3.string().max(MAX_TASK_TEXT_CHARS),
453
+ activeForm: z3.string().max(MAX_TASK_TEXT_CHARS).optional(),
454
+ status: z3.enum(TASK_STATUSES)
455
+ });
391
456
  var daemonMessageSchema = z3.discriminatedUnion("type", [
392
457
  z3.object({ type: z3.literal("session.list"), sessions: z3.array(localSessionSchema) }),
393
458
  z3.object({
@@ -445,13 +510,25 @@ var daemonMessageSchema = z3.discriminatedUnion("type", [
445
510
  type: z3.literal("workspace.plan"),
446
511
  sessionId: z3.string(),
447
512
  requestId: z3.string(),
448
- plan: z3.string().max(MAX_PLAN_CHARS).nullable()
513
+ plan: z3.string().max(MAX_PLAN_CHARS).nullable(),
514
+ tasks: z3.array(sessionTaskSchema).max(MAX_PLAN_TASKS).default([])
449
515
  }),
450
516
  z3.object({
451
517
  type: z3.literal("project.pathValidated"),
452
518
  requestId: z3.string(),
453
519
  result: projectPathValidationSchema
454
520
  }),
521
+ z3.object({
522
+ type: z3.literal("worktree.listed"),
523
+ requestId: z3.string(),
524
+ worktrees: z3.array(projectWorktreeSchema).max(MAX_PROJECT_WORKTREES),
525
+ truncated: z3.boolean()
526
+ }),
527
+ z3.object({
528
+ type: z3.literal("worktree.removed"),
529
+ requestId: z3.string(),
530
+ result: worktreeRemoveResultSchema
531
+ }),
455
532
  z3.object({
456
533
  type: z3.literal("daemon.info"),
457
534
  hostname: z3.string(),
@@ -541,7 +618,9 @@ var SIGNABLE_ACTIONS = [
541
618
  "workspace.getDiff",
542
619
  "workspace.readFileRange",
543
620
  "workspace.getPlan",
544
- "project.validatePath"
621
+ "project.validatePath",
622
+ "worktree.list",
623
+ "worktree.remove"
545
624
  ];
546
625
  var envelopeSchema = z4.object({
547
626
  version: z4.literal(1),
@@ -1043,7 +1122,7 @@ async function callTRPCQuery(baseUrl, procedure, input) {
1043
1122
  // src/pairing/pairing.ts
1044
1123
  var DEFAULT_POLL_INTERVAL_MS = 3e3;
1045
1124
  function sleep(ms) {
1046
- return new Promise((resolve2) => setTimeout(resolve2, ms));
1125
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1047
1126
  }
1048
1127
  async function ensurePaired(options) {
1049
1128
  const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
@@ -1181,8 +1260,8 @@ async function bindFirstController(params, response) {
1181
1260
  }
1182
1261
 
1183
1262
  // src/projects/validate-path.ts
1184
- import { stat as stat2 } from "fs/promises";
1185
- import { isAbsolute as isAbsolute2, resolve } from "path";
1263
+ import { stat } from "fs/promises";
1264
+ import { isAbsolute, resolve } from "path";
1186
1265
 
1187
1266
  // src/sessions/expand-home.ts
1188
1267
  import { homedir } from "os";
@@ -1252,7 +1331,7 @@ function runGitDiffAllowingExitCodes(args, cwd, allowedExitCodes) {
1252
1331
  var GIT_INPUT_TIMEOUT_MS = 3e4;
1253
1332
  async function runGitWithInput(args, cwd, input, options = {}) {
1254
1333
  const { allowedExitCodes = [], timeoutMs = GIT_INPUT_TIMEOUT_MS } = options;
1255
- return new Promise((resolve2, reject) => {
1334
+ return new Promise((resolve3, reject) => {
1256
1335
  const child = spawn2("git", [...SAFE_GIT_CONFIG, ...args], { cwd });
1257
1336
  const stdout = [];
1258
1337
  const stderr = [];
@@ -1276,7 +1355,7 @@ async function runGitWithInput(args, cwd, input, options = {}) {
1276
1355
  "close",
1277
1356
  (code) => settle(() => {
1278
1357
  if (code === 0 || code !== null && allowedExitCodes.includes(code)) {
1279
- resolve2(Buffer.concat(stdout).toString("utf8"));
1358
+ resolve3(Buffer.concat(stdout).toString("utf8"));
1280
1359
  return;
1281
1360
  }
1282
1361
  const message = Buffer.concat(stderr).toString("utf8").trim();
@@ -1287,134 +1366,13 @@ async function runGitWithInput(args, cwd, input, options = {}) {
1287
1366
  });
1288
1367
  }
1289
1368
 
1290
- // src/sessions/files/plan-file.ts
1291
- import { constants as constants2 } from "fs";
1292
- import { open as open2 } from "fs/promises";
1369
+ // src/sessions/agent-reports/layout.ts
1293
1370
  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
1371
+ import { join as join2 } from "path";
1372
+ var HOOK_PLANS_DIR = join2(homedir2(), ".bondsmith", "plans");
1373
+ var PLAN_FILE_SUFFIX = ".md";
1374
+ var TASKS_FILE_SUFFIX = ".tasks.jsonl";
1387
1375
  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
1376
 
1419
1377
  // src/sessions/tmux/client.ts
1420
1378
  import { execFile as execFileCb2 } from "child_process";
@@ -1462,8 +1420,8 @@ async function runTmux(args, options) {
1462
1420
  throw error2;
1463
1421
  }
1464
1422
  }
1465
- async function sendKeysLiteral(tmuxSessionName, text) {
1466
- await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text]);
1423
+ async function sendKeysLiteral(tmuxSessionName, text2) {
1424
+ await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text2]);
1467
1425
  }
1468
1426
  async function setMouseMode(tmuxSessionName, enabled) {
1469
1427
  await runTmux(["set-option", "-t", tmuxSessionName, "mouse", enabled ? "on" : "off"]);
@@ -1507,8 +1465,8 @@ var NO_SERVER_MESSAGE_PATTERNS = [
1507
1465
  function isNoServerError(error2) {
1508
1466
  if (typeof error2 !== "object" || error2 === null) return false;
1509
1467
  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));
1468
+ const text2 = [message, stderr].filter((part) => typeof part === "string").join("\n");
1469
+ return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(text2));
1512
1470
  }
1513
1471
 
1514
1472
  // src/sessions/git/base.ts
@@ -1545,14 +1503,14 @@ async function resolveComparisonBase(cwd) {
1545
1503
  }
1546
1504
 
1547
1505
  // src/sessions/git/baseline.ts
1548
- import { lstat as lstat2 } from "fs/promises";
1549
- import { join as join4 } from "path";
1506
+ import { lstat } from "fs/promises";
1507
+ import { join as join3 } from "path";
1550
1508
  var SESSION_START_REF = "session start";
1551
1509
  var MAX_PREEXISTING_FILES = 200;
1552
1510
  var DELETED = "deleted";
1553
1511
  async function fingerprintFile(cwd, path) {
1554
1512
  try {
1555
- const stats = await lstat2(join4(cwd, path));
1513
+ const stats = await lstat(join3(cwd, path));
1556
1514
  return `${stats.size}:${Math.trunc(stats.mtimeMs)}`;
1557
1515
  } catch {
1558
1516
  return DELETED;
@@ -1876,23 +1834,23 @@ function gitFailure(error2, path) {
1876
1834
  "git isn't installed, or isn't on the PATH of the terminal running bondsmithd."
1877
1835
  );
1878
1836
  }
1879
- const text = typeof stderr === "string" ? stderr : "";
1880
- if (text.includes("dubious ownership")) {
1837
+ const text2 = typeof stderr === "string" ? stderr : "";
1838
+ if (text2.includes("dubious ownership")) {
1881
1839
  return new Error(
1882
1840
  `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
1841
  );
1884
1842
  }
1885
- const fatal = text.split("\n").find((line) => line.startsWith("fatal:"));
1843
+ const fatal = text2.split("\n").find((line) => line.startsWith("fatal:"));
1886
1844
  return new Error(
1887
1845
  fatal ?? (error2 instanceof Error ? error2.message : "git failed to inspect that folder.")
1888
1846
  );
1889
1847
  }
1890
1848
  async function validateProjectPath(input) {
1891
1849
  const expanded = expandHome(input.trim());
1892
- if (!isAbsolute2(expanded)) return { valid: false, reason: "relative_path" };
1850
+ if (!isAbsolute(expanded)) return { valid: false, reason: "relative_path" };
1893
1851
  const path = resolve(expanded);
1894
1852
  try {
1895
- const stats = await stat2(path);
1853
+ const stats = await stat(path);
1896
1854
  if (!stats.isDirectory()) return { valid: false, reason: "not_a_directory" };
1897
1855
  } catch (error2) {
1898
1856
  const reason = invalidReasonForFsError(error2);
@@ -1919,7 +1877,7 @@ import { randomBytes, verify as verify2 } from "crypto";
1919
1877
  // src/security/trusted-controllers.ts
1920
1878
  import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
1921
1879
  import { homedir as homedir3 } from "os";
1922
- import { dirname, join as join5 } from "path";
1880
+ import { dirname, join as join4 } from "path";
1923
1881
  import { z as z12 } from "zod";
1924
1882
  var trustedControllerSchema = z12.object({
1925
1883
  id: z12.string(),
@@ -1942,7 +1900,7 @@ var trustedControllersFileSchema = z12.object({
1942
1900
  controllers: z12.array(trustedControllerSchema)
1943
1901
  });
1944
1902
  function defaultTrustedControllersPath() {
1945
- return join5(homedir3(), ".bondsmith", "trusted-controllers.json");
1903
+ return join4(homedir3(), ".bondsmith", "trusted-controllers.json");
1946
1904
  }
1947
1905
  async function loadTrustedControllers(path = defaultTrustedControllersPath()) {
1948
1906
  let raw;
@@ -2263,12 +2221,12 @@ var ConnectionAuthState = class {
2263
2221
  };
2264
2222
 
2265
2223
  // 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"];
2224
+ import { accessSync, constants, statSync } from "fs";
2225
+ import { delimiter, join as join5 } from "path";
2226
+ var KNOWN_AGENTS = AGENT_IDS;
2269
2227
  function isExecutableFile(path) {
2270
2228
  try {
2271
- accessSync(path, constants3.X_OK);
2229
+ accessSync(path, constants.X_OK);
2272
2230
  return statSync(path).isFile();
2273
2231
  } catch {
2274
2232
  return false;
@@ -2277,12 +2235,12 @@ function isExecutableFile(path) {
2277
2235
  function listInstalledAgents(env = process.env) {
2278
2236
  const directories = (env.PATH ?? "").split(delimiter).filter((directory) => directory !== "");
2279
2237
  return KNOWN_AGENTS.filter(
2280
- (agent) => directories.some((directory) => isExecutableFile(join6(directory, agent)))
2238
+ (agent) => directories.some((directory) => isExecutableFile(join5(directory, agent)))
2281
2239
  );
2282
2240
  }
2283
2241
 
2284
2242
  // src/version.ts
2285
- var DAEMON_VERSION = true ? "0.1.15" : "dev";
2243
+ var DAEMON_VERSION = true ? "0.1.17" : "dev";
2286
2244
  var VERSION_FLAGS = ["--version", "-v"];
2287
2245
  function versionLine() {
2288
2246
  return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
@@ -2291,6 +2249,241 @@ function isVersionRequest(argv) {
2291
2249
  return argv.some((arg) => VERSION_FLAGS.includes(arg));
2292
2250
  }
2293
2251
 
2252
+ // src/sessions/agent-reports/read-file.ts
2253
+ import { constants as constants3 } from "fs";
2254
+ import { open as open2 } from "fs/promises";
2255
+
2256
+ // src/sessions/files/read-range.ts
2257
+ import { constants as constants2 } from "fs";
2258
+ import { lstat as lstat2, open, realpath, stat as stat2 } from "fs/promises";
2259
+ import { isAbsolute as isAbsolute2, join as join6, sep } from "path";
2260
+ var MAX_RANGE_FILE_BYTES = 2 * 1024 * 1024;
2261
+ var REPLY_ENVELOPE_BYTES = 8 * 1024;
2262
+ var BINARY_SNIFF_BYTES = 8e3;
2263
+ function fileVersion(stats) {
2264
+ return `${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}:${stats.ino}`;
2265
+ }
2266
+ async function locateInside(cwd, path) {
2267
+ if (isAbsolute2(path)) return { refused: "not_allowed" };
2268
+ try {
2269
+ const root = await realpath(cwd);
2270
+ const lexical = join6(root, path);
2271
+ if ((await lstat2(lexical)).isSymbolicLink()) return { refused: "not_allowed" };
2272
+ const real = await realpath(lexical);
2273
+ return real === root || real.startsWith(root + sep) ? { found: real } : { refused: "missing" };
2274
+ } catch {
2275
+ return { refused: "missing" };
2276
+ }
2277
+ }
2278
+ async function readExactly(handle, size) {
2279
+ const buffer = Buffer.allocUnsafe(size + 1);
2280
+ let filled = 0;
2281
+ while (filled < buffer.length) {
2282
+ const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
2283
+ if (bytesRead === 0) break;
2284
+ filled += bytesRead;
2285
+ }
2286
+ return filled === size ? buffer.subarray(0, size) : void 0;
2287
+ }
2288
+ async function currentFileVersion(cwd, path) {
2289
+ const located = await locateInside(cwd, path);
2290
+ if (!("found" in located)) return void 0;
2291
+ try {
2292
+ return fileVersion(await stat2(located.found));
2293
+ } catch {
2294
+ return void 0;
2295
+ }
2296
+ }
2297
+ function splitLines(text2) {
2298
+ if (text2 === "") return [];
2299
+ const lines = text2.split("\n");
2300
+ if (lines[lines.length - 1] === "") lines.pop();
2301
+ return lines.map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
2302
+ }
2303
+ async function readFileRange(cwd, path, startLine, lineCount, version) {
2304
+ const refuse = (reason) => ({ ok: false, path, reason });
2305
+ const located = await locateInside(cwd, path);
2306
+ if (!("found" in located)) return refuse(located.refused);
2307
+ let content;
2308
+ let handle;
2309
+ try {
2310
+ handle = await open(
2311
+ located.found,
2312
+ constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK
2313
+ );
2314
+ const before = await handle.stat();
2315
+ if (!before.isFile()) return refuse("not_allowed");
2316
+ if (fileVersion(before) !== version) return refuse("changed");
2317
+ if (before.size > MAX_RANGE_FILE_BYTES) return refuse("too_large");
2318
+ const bytes = await readExactly(handle, before.size);
2319
+ if (bytes === void 0 || fileVersion(await handle.stat()) !== version) {
2320
+ return refuse("changed");
2321
+ }
2322
+ content = bytes;
2323
+ } catch {
2324
+ return refuse("missing");
2325
+ } finally {
2326
+ await handle?.close();
2327
+ }
2328
+ if (content.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return refuse("binary");
2329
+ const lines = splitLines(content.toString("utf8"));
2330
+ const page = [];
2331
+ let budget = MAX_DAEMON_REPLY_BYTES - REPLY_ENVELOPE_BYTES;
2332
+ const wanted = Math.min(lineCount, MAX_RANGE_LINES);
2333
+ for (let index = startLine - 1; index < lines.length && page.length < wanted; index++) {
2334
+ const line = lines[index];
2335
+ const cost = Buffer.byteLength(JSON.stringify(line)) + 1;
2336
+ if (cost > budget) {
2337
+ if (page.length === 0) return refuse("too_large");
2338
+ break;
2339
+ }
2340
+ budget -= cost;
2341
+ page.push(line);
2342
+ }
2343
+ return { ok: true, path, startLine, lines: page, totalLines: lines.length, version };
2344
+ }
2345
+
2346
+ // src/sessions/agent-reports/read-file.ts
2347
+ var ABSENT_CODES = /* @__PURE__ */ new Set(["ENOENT", "ELOOP", "ENOTDIR"]);
2348
+ async function readFileAt(root, relativePath, read) {
2349
+ const located = await locateInside(root, relativePath);
2350
+ if (!("found" in located)) return null;
2351
+ let handle;
2352
+ try {
2353
+ handle = await open2(
2354
+ located.found,
2355
+ constants3.O_RDONLY | constants3.O_NOFOLLOW | constants3.O_NONBLOCK
2356
+ );
2357
+ const info = await handle.stat();
2358
+ if (!info.isFile()) return null;
2359
+ return await read(handle, info.size);
2360
+ } catch (error2) {
2361
+ if (ABSENT_CODES.has(error2.code ?? "")) return null;
2362
+ throw error2;
2363
+ } finally {
2364
+ await handle?.close();
2365
+ }
2366
+ }
2367
+
2368
+ // src/sessions/agent-reports/task-list.ts
2369
+ import { z as z13 } from "zod";
2370
+ var text = z13.string().refine((value) => value.trim() !== "");
2371
+ var optionalText = text.optional().catch(void 0);
2372
+ var taskStatus = z13.enum(TASK_STATUSES).optional().catch(void 0);
2373
+ var taskId = z13.union([z13.string(), z13.number()]).transform(String);
2374
+ var taskEventSchema = z13.discriminatedUnion("op", [
2375
+ z13.object({
2376
+ op: z13.literal("add"),
2377
+ id: taskId.optional().catch(void 0),
2378
+ subject: text,
2379
+ activeForm: optionalText
2380
+ }),
2381
+ z13.object({
2382
+ op: z13.literal("update"),
2383
+ id: taskId,
2384
+ subject: optionalText,
2385
+ activeForm: optionalText,
2386
+ status: taskStatus
2387
+ }),
2388
+ z13.object({ op: z13.literal("remove"), id: taskId }),
2389
+ z13.object({ op: z13.literal("replace"), tasks: z13.array(z13.unknown()) })
2390
+ ]);
2391
+ var replacementTaskSchema = z13.object({
2392
+ subject: text,
2393
+ activeForm: optionalText,
2394
+ status: taskStatus
2395
+ });
2396
+ function applyEvent(tasks, event) {
2397
+ switch (event.op) {
2398
+ case "replace":
2399
+ return event.tasks.flatMap((candidate) => {
2400
+ const task = replacementTaskSchema.safeParse(candidate);
2401
+ if (!task.success) return [];
2402
+ const { subject, activeForm, status = "pending" } = task.data;
2403
+ return [{ id: null, subject, activeForm, status }];
2404
+ });
2405
+ case "add":
2406
+ return [
2407
+ ...tasks,
2408
+ {
2409
+ id: event.id ?? null,
2410
+ subject: event.subject,
2411
+ activeForm: event.activeForm,
2412
+ status: "pending"
2413
+ }
2414
+ ];
2415
+ case "update":
2416
+ return tasks.map(
2417
+ (task) => task.id === event.id ? {
2418
+ ...task,
2419
+ subject: event.subject ?? task.subject,
2420
+ activeForm: event.activeForm ?? task.activeForm,
2421
+ status: event.status ?? task.status
2422
+ } : task
2423
+ );
2424
+ case "remove":
2425
+ return tasks.filter((task) => task.id !== event.id);
2426
+ }
2427
+ }
2428
+ var cut = (value) => value.slice(0, MAX_TASK_TEXT_CHARS);
2429
+ function replayTasks(log) {
2430
+ let tasks = [];
2431
+ for (const line of log.split("\n")) {
2432
+ if (!line.trim()) continue;
2433
+ let raw;
2434
+ try {
2435
+ raw = JSON.parse(line);
2436
+ } catch {
2437
+ continue;
2438
+ }
2439
+ const event = taskEventSchema.safeParse(raw);
2440
+ if (event.success) tasks = applyEvent(tasks, event.data);
2441
+ }
2442
+ return tasks.slice(0, MAX_PLAN_TASKS).map(({ subject, activeForm, status }) => ({
2443
+ subject: cut(subject),
2444
+ ...activeForm && { activeForm: cut(activeForm) },
2445
+ status
2446
+ }));
2447
+ }
2448
+
2449
+ // src/sessions/agent-reports/readers.ts
2450
+ var MAX_PLAN_BYTES = MAX_PLAN_CHARS * 4;
2451
+ var MAX_TASK_LOG_BYTES = 1024 * 1024;
2452
+ async function readPlanAt(root, relativePath) {
2453
+ const text2 = await readFileAt(root, relativePath, async (handle) => {
2454
+ const buffer = Buffer.alloc(MAX_PLAN_BYTES);
2455
+ const { bytesRead } = await handle.read(buffer, 0, MAX_PLAN_BYTES, 0);
2456
+ return buffer.toString("utf8", 0, bytesRead).trim();
2457
+ });
2458
+ return text2 ? text2.slice(0, MAX_PLAN_CHARS) : null;
2459
+ }
2460
+ function readPlanFile(cwd) {
2461
+ return readPlanAt(cwd, PLAN_FILE_PATH);
2462
+ }
2463
+ function readHookPlan(sessionId, plansDir = HOOK_PLANS_DIR) {
2464
+ return readPlanAt(plansDir, `${sessionId}${PLAN_FILE_SUFFIX}`);
2465
+ }
2466
+ async function readHookTasks(sessionId, plansDir = HOOK_PLANS_DIR) {
2467
+ const log = await readFileAt(
2468
+ plansDir,
2469
+ `${sessionId}${TASKS_FILE_SUFFIX}`,
2470
+ async (handle, size) => {
2471
+ const length = Math.min(size, MAX_TASK_LOG_BYTES);
2472
+ const buffer = Buffer.alloc(length);
2473
+ const { bytesRead } = await handle.read(buffer, 0, length, size - length);
2474
+ const text2 = buffer.toString("utf8", 0, bytesRead);
2475
+ return length < size ? text2.slice(text2.indexOf("\n") + 1) : text2;
2476
+ }
2477
+ );
2478
+ return log === null ? [] : replayTasks(log);
2479
+ }
2480
+
2481
+ // src/sessions/agent-reports/session-report.ts
2482
+ async function readSessionReport(session) {
2483
+ const plan = await readHookPlan(session.id) ?? await readPlanFile(session.cwd) ?? await getSessionPlan(session.tmuxSessionName);
2484
+ return { plan, tasks: await readHookTasks(session.id) };
2485
+ }
2486
+
2294
2487
  // src/security/verify-command.ts
2295
2488
  import { verify as verify3 } from "crypto";
2296
2489
  var ENVELOPE_MAX_AGE_MS = 6e4;
@@ -2427,8 +2620,8 @@ function createOutputPacer(options) {
2427
2620
 
2428
2621
  // src/ws/sanitize-insert-text.ts
2429
2622
  var DISALLOWED_CHARACTERS = /[\x00-\x08\x0b-\x1f\x7f-\x9f‪-‮⁦-⁩]/g;
2430
- function sanitizeInsertText(text) {
2431
- const withLineFeeds = text.replace(/\r\n?/g, "\n");
2623
+ function sanitizeInsertText(text2) {
2624
+ const withLineFeeds = text2.replace(/\r\n?/g, "\n");
2432
2625
  const sanitized = withLineFeeds.replace(DISALLOWED_CHARACTERS, "");
2433
2626
  return { text: sanitized, strippedCount: withLineFeeds.length - sanitized.length };
2434
2627
  }
@@ -2520,6 +2713,19 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
2520
2713
  }
2521
2714
  return { conn, releaseAllSubscriptions };
2522
2715
  }
2716
+ var removalQueues = /* @__PURE__ */ new Map();
2717
+ function inRepoOrder(repoPath, task) {
2718
+ const result = (removalQueues.get(repoPath) ?? Promise.resolve()).then(task);
2719
+ const tail = result.then(
2720
+ () => void 0,
2721
+ () => void 0
2722
+ );
2723
+ removalQueues.set(repoPath, tail);
2724
+ void tail.then(() => {
2725
+ if (removalQueues.get(repoPath) === tail) removalQueues.delete(repoPath);
2726
+ });
2727
+ return result;
2728
+ }
2523
2729
  function error(message, requestId) {
2524
2730
  return { type: "error", message, requestId };
2525
2731
  }
@@ -2649,6 +2855,8 @@ async function dispatch(conn, message, {
2649
2855
  getWorkspaceDiff: getWorkspaceDiff2,
2650
2856
  readFileRange: readFileRange2,
2651
2857
  validateProjectPath: validateProjectPath2,
2858
+ listProjectWorktrees: listProjectWorktrees2,
2859
+ removeManagedWorktree: removeManagedWorktree2,
2652
2860
  hostname: hostname3,
2653
2861
  daemonFingerprint,
2654
2862
  pendingAuthorizations,
@@ -2762,14 +2970,14 @@ async function dispatch(conn, message, {
2762
2970
  const session = getSessionOrError(sessionManager, conn, message.sessionId);
2763
2971
  if (!session) return;
2764
2972
  sessionManager.acknowledge(message.sessionId);
2765
- const { text, strippedCount } = sanitizeInsertText(message.text);
2973
+ const { text: text2, strippedCount } = sanitizeInsertText(message.text);
2766
2974
  if (strippedCount > 0) {
2767
2975
  console.warn(`bondsmithd: stripped ${strippedCount} control characters from inserted text`);
2768
2976
  }
2769
2977
  try {
2770
2978
  await sendKeysLiteral(
2771
2979
  session.tmuxSessionName,
2772
- `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`
2980
+ `${BRACKETED_PASTE_START}${text2}${BRACKETED_PASTE_END}`
2773
2981
  );
2774
2982
  } catch (err) {
2775
2983
  conn.send(error(errorMessageOf(err, "failed to insert text")));
@@ -2880,12 +3088,13 @@ async function dispatch(conn, message, {
2880
3088
  const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
2881
3089
  if (!session) return;
2882
3090
  try {
2883
- const plan = await readHookPlan(session.id) ?? await readPlanFile(session.cwd) ?? await getSessionPlan(session.tmuxSessionName);
3091
+ const { plan, tasks } = await readSessionReport(session);
2884
3092
  conn.send({
2885
3093
  type: "workspace.plan",
2886
3094
  sessionId: message.sessionId,
2887
3095
  requestId: message.requestId,
2888
- plan
3096
+ plan,
3097
+ tasks
2889
3098
  });
2890
3099
  } catch (err) {
2891
3100
  conn.send(error(errorMessageOf(err, "failed to read the session plan"), message.requestId));
@@ -2901,6 +3110,47 @@ async function dispatch(conn, message, {
2901
3110
  }
2902
3111
  return;
2903
3112
  }
3113
+ case "worktree.list": {
3114
+ try {
3115
+ const project = await validateProjectPath2(message.repoPath);
3116
+ if (!project.valid) {
3117
+ conn.send(error(`can't list worktrees: ${project.reason}`, message.requestId));
3118
+ return;
3119
+ }
3120
+ const worktrees = await listProjectWorktrees2(project.path, sessionManager.list());
3121
+ conn.send({
3122
+ type: "worktree.listed",
3123
+ requestId: message.requestId,
3124
+ ...fitWorktreeReply(worktrees)
3125
+ });
3126
+ } catch (err) {
3127
+ conn.send(error(errorMessageOf(err, "failed to list worktrees"), message.requestId));
3128
+ }
3129
+ return;
3130
+ }
3131
+ case "worktree.remove": {
3132
+ try {
3133
+ const project = await validateProjectPath2(message.repoPath);
3134
+ if (!project.valid) {
3135
+ conn.send(error(`can't remove a worktree: ${project.reason}`, message.requestId));
3136
+ return;
3137
+ }
3138
+ const result = await inRepoOrder(
3139
+ project.path,
3140
+ () => removeManagedWorktree2({
3141
+ repoPath: project.path,
3142
+ worktreePath: message.worktreePath,
3143
+ force: message.force,
3144
+ deleteBranch: message.deleteBranch,
3145
+ getSessions: () => sessionManager.list()
3146
+ })
3147
+ );
3148
+ conn.send({ type: "worktree.removed", requestId: message.requestId, result });
3149
+ } catch (err) {
3150
+ conn.send(error(errorMessageOf(err, "failed to remove the worktree"), message.requestId));
3151
+ }
3152
+ return;
3153
+ }
2904
3154
  case "daemon.getInfo": {
2905
3155
  conn.send({ type: "daemon.info", hostname: hostname3(), daemonFingerprint });
2906
3156
  return;
@@ -3319,9 +3569,9 @@ function isMatchingKeyPair(publicKeyPem, privateKeyPem) {
3319
3569
  return false;
3320
3570
  }
3321
3571
  }
3322
- function extractPemBlock(text, label) {
3572
+ function extractPemBlock(text2, label) {
3323
3573
  const pattern = new RegExp(`-----BEGIN ${label}-----[\\s\\S]*?-----END ${label}-----\\n?`);
3324
- return text.match(pattern)?.[0] ?? null;
3574
+ return text2.match(pattern)?.[0] ?? null;
3325
3575
  }
3326
3576
 
3327
3577
  // src/security/daemon-proof.ts
@@ -3460,8 +3710,8 @@ var PendingAuthorizationStore = class {
3460
3710
  requestToken: randomUUID()
3461
3711
  };
3462
3712
  let settleFn;
3463
- const resultPromise = new Promise((resolve2) => {
3464
- settleFn = resolve2;
3713
+ const resultPromise = new Promise((resolve3) => {
3714
+ settleFn = resolve3;
3465
3715
  });
3466
3716
  const entry = {
3467
3717
  authorization,
@@ -3572,16 +3822,16 @@ var PendingAuthorizationStore = class {
3572
3822
  };
3573
3823
 
3574
3824
  // src/security/revocation-sync.ts
3575
- import { z as z13 } from "zod";
3825
+ import { z as z14 } from "zod";
3576
3826
  var POLL_INTERVAL_MS = 3e4;
3577
3827
  var POLL_JITTER_MS = 6e3;
3578
3828
  var POLL_REQUEST_TIMEOUT_MS = 1e4;
3579
3829
  var BACKOFF_BASE_MS = 2e3;
3580
3830
  var BACKOFF_MAX_MS = 2e4;
3581
3831
  var MAX_CONSECUTIVE_FAILURE_BACKOFFS = 4;
3582
- var checkRevocationStatusResponseSchema = z13.object({
3583
- pairingRevoked: z13.boolean(),
3584
- revokedControllerPublicKeys: z13.array(z13.string())
3832
+ var checkRevocationStatusResponseSchema = z14.object({
3833
+ pairingRevoked: z14.boolean(),
3834
+ revokedControllerPublicKeys: z14.array(z14.string())
3585
3835
  });
3586
3836
  function jitteredInterval(deps) {
3587
3837
  const interval = deps.pollIntervalMs ?? POLL_INTERVAL_MS;
@@ -3801,6 +4051,256 @@ async function getWorkspaceDiff(cwd, path, staged, baseline) {
3801
4051
  }
3802
4052
  }
3803
4053
 
4054
+ // src/sessions/git/managed-worktree.ts
4055
+ import { realpath as realpath4 } from "fs/promises";
4056
+ import { sep as sep2 } from "path";
4057
+
4058
+ // src/sessions/git/worktree.ts
4059
+ import { execFile as execFileCb3 } from "child_process";
4060
+ import { promisify as promisify3 } from "util";
4061
+ var execFile3 = promisify3(execFileCb3);
4062
+ var WorktreeBranchNameTakenError = class extends Error {
4063
+ constructor(branch) {
4064
+ super(`a branch named "${branch}" already exists`);
4065
+ this.name = "WorktreeBranchNameTakenError";
4066
+ }
4067
+ };
4068
+ var WorktreePathExistsError = class extends Error {
4069
+ constructor(path) {
4070
+ super(`worktree path already exists: ${path}`);
4071
+ this.name = "WorktreePathExistsError";
4072
+ }
4073
+ };
4074
+ var WorktreeHasUncommittedChangesError = class extends Error {
4075
+ constructor(path) {
4076
+ super(
4077
+ `worktree at ${path} has uncommitted or untracked changes \u2014 not removing it. Commit or discard the changes, then remove it yourself (git worktree remove ${path}).`
4078
+ );
4079
+ this.name = "WorktreeHasUncommittedChangesError";
4080
+ }
4081
+ };
4082
+ function stderrOf(error2) {
4083
+ if (typeof error2 !== "object" || error2 === null) return "";
4084
+ const stderr = error2.stderr;
4085
+ return typeof stderr === "string" ? stderr : "";
4086
+ }
4087
+ async function createWorktree(sourceCwd, worktreePath, branch, startPoint) {
4088
+ const args = ["worktree", "add", "--no-track", "-b", branch, worktreePath];
4089
+ if (startPoint) args.push(startPoint);
4090
+ try {
4091
+ await execFile3("git", [...SAFE_GIT_CONFIG, ...args], { cwd: sourceCwd });
4092
+ } catch (error2) {
4093
+ const stderr = stderrOf(error2);
4094
+ if (stderr.includes("a branch named")) {
4095
+ throw new WorktreeBranchNameTakenError(branch);
4096
+ }
4097
+ if (stderr.includes("already exists")) {
4098
+ throw new WorktreePathExistsError(worktreePath);
4099
+ }
4100
+ throw error2;
4101
+ }
4102
+ }
4103
+ async function removeWorktree(sourceCwd, worktreePath, { force = false } = {}) {
4104
+ const args = ["worktree", "remove", ...force ? ["--force"] : [], "--", worktreePath];
4105
+ try {
4106
+ await execFile3("git", [...SAFE_GIT_CONFIG, ...args], { cwd: sourceCwd });
4107
+ } catch (error2) {
4108
+ const stderr = stderrOf(error2);
4109
+ if (stderr.includes("contains modified or untracked files")) {
4110
+ throw new WorktreeHasUncommittedChangesError(worktreePath);
4111
+ }
4112
+ throw error2;
4113
+ }
4114
+ }
4115
+ var BranchNotMergedError = class extends Error {
4116
+ constructor(branch) {
4117
+ super(`the branch "${branch}" is not fully merged, so it was kept`);
4118
+ this.name = "BranchNotMergedError";
4119
+ }
4120
+ };
4121
+ async function deleteMergedBranch(sourceCwd, branch) {
4122
+ try {
4123
+ await execFile3("git", [...SAFE_GIT_CONFIG, "branch", "-d", "--", branch], { cwd: sourceCwd });
4124
+ } catch (error2) {
4125
+ if (stderrOf(error2).includes("not fully merged")) throw new BranchNotMergedError(branch);
4126
+ throw error2;
4127
+ }
4128
+ }
4129
+
4130
+ // src/sessions/git/worktree-list.ts
4131
+ import { realpath as realpath3 } from "fs/promises";
4132
+
4133
+ // src/sessions/worktree-root.ts
4134
+ import { lstat as lstat3, realpath as realpath2 } from "fs/promises";
4135
+ import { homedir as homedir6 } from "os";
4136
+ import { basename, dirname as dirname4, isAbsolute as isAbsolute3, join as join9, resolve as resolve2 } from "path";
4137
+ function worktreeRoot() {
4138
+ return join9(homedir6(), ".bondsmith", "worktrees");
4139
+ }
4140
+ function worktreePathFor(sessionId) {
4141
+ return join9(worktreeRoot(), sessionId);
4142
+ }
4143
+ async function canonicalPath(path) {
4144
+ try {
4145
+ return await realpath2(path);
4146
+ } catch (error2) {
4147
+ const code = error2.code;
4148
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw error2;
4149
+ const parent = dirname4(path);
4150
+ if (parent === path) return path;
4151
+ return join9(await canonicalPath(parent), basename(path));
4152
+ }
4153
+ }
4154
+ async function managedWorktreeLocation(path, root = worktreeRoot()) {
4155
+ if (!isAbsolute3(path) || path.includes("\0") || path !== resolve2(path)) return null;
4156
+ const name = basename(path);
4157
+ if (!isValidSessionId(name)) return null;
4158
+ const realRoot = await canonicalPath(root);
4159
+ if (await canonicalPath(dirname4(path)) !== realRoot) return null;
4160
+ return join9(realRoot, name);
4161
+ }
4162
+ async function requireManagedWorktreeDirectory(path, root = worktreeRoot()) {
4163
+ const location = await managedWorktreeLocation(path, root);
4164
+ if (!location) return null;
4165
+ try {
4166
+ if (!(await lstat3(location)).isDirectory()) return null;
4167
+ return await realpath2(location);
4168
+ } catch (error2) {
4169
+ if (error2.code === "ENOENT") return null;
4170
+ throw error2;
4171
+ }
4172
+ }
4173
+
4174
+ // src/sessions/git/worktree-list.ts
4175
+ var BRANCH_REF_PREFIX = "refs/heads/";
4176
+ function parseWorktreeList(output) {
4177
+ const entries = [];
4178
+ let current = null;
4179
+ for (const field of output.split("\0")) {
4180
+ if (field === "") {
4181
+ if (current) entries.push(current);
4182
+ current = null;
4183
+ } else if (field.startsWith("worktree ")) {
4184
+ current = {
4185
+ path: field.slice("worktree ".length),
4186
+ branch: null,
4187
+ head: "",
4188
+ prunable: false,
4189
+ locked: false
4190
+ };
4191
+ } else if (current && field.startsWith("HEAD ")) {
4192
+ current.head = field.slice("HEAD ".length);
4193
+ } else if (current && field.startsWith("branch ")) {
4194
+ const ref = field.slice("branch ".length);
4195
+ current.branch = ref.startsWith(BRANCH_REF_PREFIX) ? ref.slice(BRANCH_REF_PREFIX.length) : ref;
4196
+ } else if (current && (field === "prunable" || field.startsWith("prunable "))) {
4197
+ current.prunable = true;
4198
+ } else if (current && (field === "locked" || field.startsWith("locked "))) {
4199
+ current.locked = true;
4200
+ }
4201
+ }
4202
+ if (current) entries.push(current);
4203
+ return entries;
4204
+ }
4205
+ async function listDaemonWorktrees(repoPath) {
4206
+ const [, ...linked] = parseWorktreeList(
4207
+ await runGit(["worktree", "list", "--porcelain", "-z"], repoPath)
4208
+ );
4209
+ const ownPath = await realpath3(repoPath);
4210
+ const locations = await Promise.all(linked.map((entry) => managedWorktreeLocation(entry.path)));
4211
+ return linked.flatMap((entry, index) => {
4212
+ const location = locations[index];
4213
+ return location && location !== ownPath ? [{ ...entry, path: location }] : [];
4214
+ });
4215
+ }
4216
+ async function findManagedWorktree(repoPath, requestedPath) {
4217
+ const location = await requireManagedWorktreeDirectory(requestedPath);
4218
+ if (!location) return null;
4219
+ const entries = await listDaemonWorktrees(repoPath);
4220
+ return entries.find((entry) => entry.path === location) ?? null;
4221
+ }
4222
+
4223
+ // src/sessions/git/managed-worktree.ts
4224
+ var STATUS_CONCURRENCY = 8;
4225
+ async function mapWithConcurrency(items, limit, map) {
4226
+ const results = new Array(items.length);
4227
+ let next = 0;
4228
+ let failed = false;
4229
+ const worker = async () => {
4230
+ while (!failed && next < items.length) {
4231
+ const index = next++;
4232
+ try {
4233
+ results[index] = await map(items[index]);
4234
+ } catch (error2) {
4235
+ failed = true;
4236
+ throw error2;
4237
+ }
4238
+ }
4239
+ };
4240
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
4241
+ return results;
4242
+ }
4243
+ async function sessionInWorktree(worktreePath, sessions) {
4244
+ for (const session of sessions) {
4245
+ const real = await realpath4(session.cwd).catch(() => session.cwd);
4246
+ const inside = [session.cwd, real].some(
4247
+ (cwd) => cwd === worktreePath || cwd.startsWith(`${worktreePath}${sep2}`)
4248
+ );
4249
+ if (inside) return session;
4250
+ }
4251
+ return null;
4252
+ }
4253
+ async function hasChanges(worktreePath) {
4254
+ try {
4255
+ return (await runGit(["status", "--porcelain", "--untracked-files=normal"], worktreePath)).trim() !== "";
4256
+ } catch {
4257
+ return true;
4258
+ }
4259
+ }
4260
+ async function listProjectWorktrees(repoPath, sessions) {
4261
+ const entries = await listDaemonWorktrees(repoPath);
4262
+ return mapWithConcurrency(entries, STATUS_CONCURRENCY, async (entry) => ({
4263
+ path: entry.path,
4264
+ branch: entry.branch,
4265
+ head: entry.head,
4266
+ prunable: entry.prunable,
4267
+ locked: entry.locked,
4268
+ dirty: entry.prunable ? false : await hasChanges(entry.path),
4269
+ sessionId: (await sessionInWorktree(entry.path, sessions))?.id ?? null
4270
+ }));
4271
+ }
4272
+ async function dropBranch(repoPath, branch) {
4273
+ try {
4274
+ await deleteMergedBranch(repoPath, branch);
4275
+ return "deleted";
4276
+ } catch (error2) {
4277
+ if (error2 instanceof BranchNotMergedError) return "not_merged";
4278
+ console.error("Failed to delete the branch of a removed worktree", { branch, error: error2 });
4279
+ return "failed";
4280
+ }
4281
+ }
4282
+ async function removeManagedWorktree(input) {
4283
+ const entry = await findManagedWorktree(input.repoPath, input.worktreePath);
4284
+ if (!entry) return { removed: false, reason: "not_found" };
4285
+ if (entry.locked) return { removed: false, reason: "locked" };
4286
+ if (await sessionInWorktree(entry.path, input.getSessions())) {
4287
+ return { removed: false, reason: "session_active" };
4288
+ }
4289
+ try {
4290
+ await removeWorktree(input.repoPath, entry.path, { force: input.force });
4291
+ } catch (error2) {
4292
+ if (error2 instanceof WorktreeHasUncommittedChangesError) {
4293
+ return { removed: false, reason: "has_changes" };
4294
+ }
4295
+ throw error2;
4296
+ }
4297
+ const branch = input.deleteBranch && entry.branch ? await dropBranch(input.repoPath, entry.branch) : "kept";
4298
+ console.log(
4299
+ `Removed worktree ${entry.path} (branch ${entry.branch ?? "none"}: ${branch}${input.force ? ", forced" : ""})`
4300
+ );
4301
+ return { removed: true, branch };
4302
+ }
4303
+
3804
4304
  // src/ws/server.ts
3805
4305
  import { hostname } from "os";
3806
4306
  import { WebSocketServer } from "ws";
@@ -3931,63 +4431,43 @@ ${new TmuxNotFoundError().message}
3931
4431
  // src/sessions/session-manager.ts
3932
4432
  import { v4 as uuidv4 } from "uuid";
3933
4433
 
3934
- // src/sessions/git/worktree.ts
3935
- import { execFile as execFileCb3 } from "child_process";
3936
- import { promisify as promisify3 } from "util";
3937
- var execFile3 = promisify3(execFileCb3);
3938
- var WorktreeBranchNameTakenError = class extends Error {
3939
- constructor(branch) {
3940
- super(`a branch named "${branch}" already exists`);
3941
- this.name = "WorktreeBranchNameTakenError";
3942
- }
3943
- };
3944
- var WorktreePathExistsError = class extends Error {
3945
- constructor(path) {
3946
- super(`worktree path already exists: ${path}`);
3947
- this.name = "WorktreePathExistsError";
3948
- }
3949
- };
3950
- var WorktreeHasUncommittedChangesError = class extends Error {
3951
- constructor(path) {
3952
- super(
3953
- `worktree at ${path} has uncommitted or untracked changes \u2014 not removing it. Commit or discard the changes, then remove it yourself (git worktree remove ${path}).`
3954
- );
3955
- this.name = "WorktreeHasUncommittedChangesError";
3956
- }
3957
- };
3958
- function stderrOf(error2) {
3959
- if (typeof error2 !== "object" || error2 === null) return "";
3960
- const stderr = error2.stderr;
3961
- return typeof stderr === "string" ? stderr : "";
4434
+ // src/sessions/agent-reports/lifecycle.ts
4435
+ import { readdir, rm as rm4, stat as stat3 } from "fs/promises";
4436
+ import { basename as basename2, join as join10 } from "path";
4437
+ var SWEEP_MIN_AGE_MS = 6e4;
4438
+ async function removeHookPlanFiles(sessionId, plansDir = HOOK_PLANS_DIR) {
4439
+ await Promise.all(
4440
+ [PLAN_FILE_SUFFIX, TASKS_FILE_SUFFIX].map(
4441
+ (suffix) => rm4(join10(plansDir, `${basename2(sessionId)}${suffix}`), { force: true })
4442
+ )
4443
+ );
3962
4444
  }
3963
- async function createWorktree(sourceCwd, worktreePath, branch, startPoint) {
3964
- const args = ["worktree", "add", "--no-track", "-b", branch, worktreePath];
3965
- if (startPoint) args.push(startPoint);
3966
- try {
3967
- await execFile3("git", [...SAFE_GIT_CONFIG, ...args], { cwd: sourceCwd });
3968
- } catch (error2) {
3969
- const stderr = stderrOf(error2);
3970
- if (stderr.includes("a branch named")) {
3971
- throw new WorktreeBranchNameTakenError(branch);
3972
- }
3973
- if (stderr.includes("already exists")) {
3974
- throw new WorktreePathExistsError(worktreePath);
3975
- }
3976
- throw error2;
4445
+ function hookFileSessionId(name) {
4446
+ for (const suffix of [PLAN_FILE_SUFFIX, TASKS_FILE_SUFFIX]) {
4447
+ const sessionId = name.endsWith(suffix) ? name.slice(0, -suffix.length) : "";
4448
+ if (sessionId && !sessionId.includes(".")) return sessionId;
3977
4449
  }
4450
+ return null;
3978
4451
  }
3979
- async function removeWorktree(sourceCwd, worktreePath) {
4452
+ async function sweepOrphanedHookPlanFiles(liveSessionIds, plansDir = HOOK_PLANS_DIR) {
4453
+ let names;
3980
4454
  try {
3981
- await execFile3("git", [...SAFE_GIT_CONFIG, "worktree", "remove", worktreePath], {
3982
- cwd: sourceCwd
3983
- });
4455
+ names = await readdir(plansDir);
3984
4456
  } catch (error2) {
3985
- const stderr = stderrOf(error2);
3986
- if (stderr.includes("contains modified or untracked files")) {
3987
- throw new WorktreeHasUncommittedChangesError(worktreePath);
3988
- }
4457
+ if (ABSENT_CODES.has(error2.code ?? "")) return;
3989
4458
  throw error2;
3990
4459
  }
4460
+ const now = Date.now();
4461
+ await Promise.all(
4462
+ names.map(async (name) => {
4463
+ const sessionId = hookFileSessionId(name);
4464
+ if (sessionId === null || liveSessionIds.has(sessionId)) return;
4465
+ const path = join10(plansDir, name);
4466
+ const modifiedMs = await stat3(path).then((info) => info.mtimeMs).catch(() => null);
4467
+ if (modifiedMs === null || now - modifiedMs < SWEEP_MIN_AGE_MS) return;
4468
+ await rm4(path, { force: true });
4469
+ })
4470
+ );
3991
4471
  }
3992
4472
 
3993
4473
  // src/sessions/worktree-setup/setup-command.ts
@@ -4029,22 +4509,15 @@ function decodeSkippedFiles(raw) {
4029
4509
 
4030
4510
  // src/sessions/tmux/agent-state.ts
4031
4511
  var AGENT_STATE_OPTION = "@bondsmith-agent-state";
4032
- var HEADLINE_OPTION = "@bondsmith-headline";
4033
4512
  var PULL_REQUESTS_OPTION = "@bondsmith-prs";
4034
4513
  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}}`;
4514
+ var LIVE_SESSION_REPORTS_FORMAT = `#{session_name} #{${AGENT_STATE_OPTION}} #{window_bell_flag} #{${SETUP_FAILED_OPTION}} #{${SETUP_SKIPPED_OPTION}} #{${PULL_REQUESTS_OPTION}}`;
4037
4515
  var AGENT_STATE_OUTPUT_GRACE_MS = 2e3;
4038
4516
  var FUTURE_REPORT_ALLOWANCE_MS = 2e3;
4039
4517
  var WORKING_SILENCE_MS = 12e4;
4040
4518
  var SETTLE_QUIET_MS = 8e3;
4041
4519
  var ACKNOWLEDGE_GRACE_MS = 3e3;
4042
4520
  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
4521
  function parsePullRequestUrls(raw) {
4049
4522
  const urls = [...new Set(raw.match(PULL_REQUEST_URL) ?? [])];
4050
4523
  return urls.slice(-MAX_SESSION_PULL_REQUESTS);
@@ -4061,8 +4534,7 @@ function parseLiveSessionReports(output) {
4061
4534
  bell = "",
4062
4535
  setupFailed = "",
4063
4536
  setupSkipped = "",
4064
- pullRequests = "",
4065
- ...headline
4537
+ pullRequests = ""
4066
4538
  ] = line.split(" ");
4067
4539
  if (sessionName) {
4068
4540
  reports.set(sessionName, {
@@ -4070,8 +4542,7 @@ function parseLiveSessionReports(output) {
4070
4542
  bell: bell === "1",
4071
4543
  setupFailed: setupFailed === "1",
4072
4544
  setupSkipped: decodeSkippedFiles(setupSkipped),
4073
- pullRequestUrls: parsePullRequestUrls(pullRequests),
4074
- headline: cleanHeadline(headline.join(" "))
4545
+ pullRequestUrls: parsePullRequestUrls(pullRequests)
4075
4546
  });
4076
4547
  }
4077
4548
  }
@@ -4122,56 +4593,67 @@ function resolveSessionAttention(report, lastActivityAt, { nowMs, acknowledgedAt
4122
4593
 
4123
4594
  // src/sessions/tmux/create.ts
4124
4595
  import { access } from "fs/promises";
4125
- import { homedir as homedir7 } from "os";
4126
- import { join as join11 } from "path";
4127
4596
 
4128
- // src/sessions/agents/claude-hooks.ts
4597
+ // src/sessions/agents/adapter.ts
4598
+ var ENV_ASSIGNMENT_WORD = String.raw`\w+=\S*`;
4599
+ var ENV_ASSIGNMENT = new RegExp(`^${ENV_ASSIGNMENT_WORD}$`);
4600
+ function leadingBinary(command) {
4601
+ return command.split(/\s+/).filter(Boolean).find((word) => !ENV_ASSIGNMENT.test(word));
4602
+ }
4603
+ function plainAdapter(id) {
4604
+ return {
4605
+ id,
4606
+ matches: (command) => leadingBinary(command) === id,
4607
+ prepareCommand: async (command) => command
4608
+ };
4609
+ }
4610
+
4611
+ // src/sessions/agents/claude/hooks.ts
4129
4612
  import { execFile as execFile4 } from "child_process";
4130
4613
  import { randomUUID as randomUUID2 } from "crypto";
4131
- import { mkdir as mkdir4, rename as rename4, rm as rm4, writeFile as writeFile4 } from "fs/promises";
4132
- import { homedir as homedir6 } from "os";
4133
- import { join as join9 } from "path";
4614
+ import { mkdir as mkdir4, rename as rename4, rm as rm5, writeFile as writeFile4 } from "fs/promises";
4615
+ import { homedir as homedir7 } from "os";
4616
+ import { join as join11 } from "path";
4134
4617
  import { promisify as promisify4 } from "util";
4135
4618
 
4136
4619
  // src/sessions/session-env.ts
4137
4620
  var SESSION_ENV_VAR = "BONDSMITH_SESSION";
4138
4621
 
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) {
4622
+ // src/sessions/agent-reports/hook-scripts.ts
4623
+ function storePlanScript(plansDir, planExpr) {
4143
4624
  return [
4144
4625
  `let d="";`,
4145
4626
  `process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
4146
- `const p=JSON.parse(d).tool_response?.plan;if(!p)return;`,
4627
+ `const j=JSON.parse(d),p=${planExpr};if(typeof p!=="string"||!p)return;`,
4147
4628
  `const fs=require("fs"),path=require("path"),dir=${JSON.stringify(plansDir)};`,
4148
4629
  // 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);`,
4630
+ `const name=path.basename(process.env.${SESSION_ENV_VAR})+${JSON.stringify(PLAN_FILE_SUFFIX)},tmp=path.join(dir,"."+name+"."+process.pid);`,
4150
4631
  // Written aside and renamed: a reader never sees half a plan, and a symlink in the way is replaced, not followed.
4151
4632
  `fs.mkdirSync(dir,{recursive:true,mode:0o700});`,
4152
4633
  `fs.writeFileSync(tmp,p,{mode:0o600});fs.renameSync(tmp,path.join(dir,name))`,
4153
4634
  `}catch{}})`
4154
4635
  ].join("");
4155
4636
  }
4156
- function headlineHookScript() {
4637
+ function appendTaskEventScript(plansDir, eventExpr) {
4157
4638
  return [
4158
4639
  `let d="";`,
4159
4640
  `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])`,
4641
+ `const j=JSON.parse(d),e=${eventExpr};if(!e)return;`,
4642
+ `const fs=require("fs"),path=require("path"),dir=${JSON.stringify(plansDir)},c=fs.constants;`,
4643
+ `fs.mkdirSync(dir,{recursive:true,mode:0o700});`,
4644
+ // O_NOFOLLOW so a symlink in the way is refused rather than written through.
4645
+ `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);`,
4646
+ `try{fs.writeSync(fd,JSON.stringify(e)+"\\n")}finally{fs.closeSync(fd)}`,
4166
4647
  `}catch{}})`
4167
4648
  ].join("");
4168
4649
  }
4650
+ var PR_CREATE_MENTION = "gh pr create";
4169
4651
  var PR_CREATE_COMMAND = /(?:^|[;&|\n(])\s*(?:\w+=\S*\s+)*gh\s+pr\s+create\b/;
4170
- function pullRequestHookScript() {
4652
+ function recordPullRequestScript(commandExpr, outputExpr) {
4171
4653
  return [
4172
4654
  `let d="";`,
4173
4655
  `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;`,
4656
+ `const j=JSON.parse(d),c=${commandExpr},o=${outputExpr};`,
4175
4657
  `if(typeof c!=="string"||typeof o!=="string"||!${PR_CREATE_COMMAND.toString()}.test(c))return;`,
4176
4658
  `const m=o.match(${PULL_REQUEST_URL.toString()});if(!m)return;const u=m[m.length-1];`,
4177
4659
  `const cp=require("child_process"),p=process.env.TMUX_PANE;`,
@@ -4182,29 +4664,49 @@ function pullRequestHookScript() {
4182
4664
  ].join("");
4183
4665
  }
4184
4666
 
4185
- // src/sessions/agents/claude-settings.ts
4667
+ // src/sessions/agent-reports/hook-commands.ts
4186
4668
  function shellQuote3(value) {
4187
4669
  return `'${value.replaceAll("'", `'\\''`)}'`;
4188
4670
  }
4189
4671
  function nodeHookCommand(script, nodePath) {
4190
4672
  return `[ -n "$${SESSION_ENV_VAR}" ] && ${shellQuote3(nodePath)} -e ${shellQuote3(script)}; exit 0`;
4191
4673
  }
4674
+ function recordPullRequestCommand(script, nodePath) {
4675
+ const run = `${shellQuote3(nodePath)} -e ${shellQuote3(script)}`;
4676
+ return `[ -n "$${SESSION_ENV_VAR}" ] && { input=$(cat); case "$input" in *"${PR_CREATE_MENTION}"*) printf '%s' "$input" | ${run};; esac; }; exit 0`;
4677
+ }
4678
+ function stateHookCommand(state) {
4679
+ return `[ -n "$${SESSION_ENV_VAR}" ] && tmux set-option -t "$TMUX_PANE" -q ${AGENT_STATE_OPTION} "${state}:$(date +%s)"; exit 0`;
4680
+ }
4681
+
4682
+ // src/sessions/agents/claude/scripts.ts
4683
+ var PLAN = `j.tool_response?.plan??j.tool_input?.plan`;
4684
+ var TASK_EVENT_BY_TOOL = {
4685
+ TaskCreate: `{op:"add",id:j.tool_response?.task?.id,subject:i.subject,activeForm:i.activeForm}`,
4686
+ TaskUpdate: `i.status==="deleted"?{op:"remove",id:i.taskId}:{op:"update",id:i.taskId,subject:i.subject,status:i.status,activeForm:i.activeForm}`,
4687
+ TodoWrite: `Array.isArray(i.todos)?{op:"replace",tasks:i.todos.map(t=>({subject:t?.content,status:t?.status,activeForm:t?.activeForm}))}:undefined`
4688
+ };
4689
+ var TASK_TOOLS = Object.keys(TASK_EVENT_BY_TOOL);
4690
+ var TASK_EVENT = `(()=>{const i=j.tool_input||{};switch(j.tool_name){${Object.entries(
4691
+ TASK_EVENT_BY_TOOL
4692
+ ).map(([tool, event]) => `case${JSON.stringify(tool)}:return ${event};`).join("")}}})()`;
4693
+ var planHookScript = (plansDir) => storePlanScript(plansDir, PLAN);
4694
+ var taskHookScript = (plansDir) => appendTaskEventScript(plansDir, TASK_EVENT);
4695
+ var pullRequestHookScript = () => recordPullRequestScript(`j.tool_input?.command`, `j.tool_response?.stdout`);
4696
+
4697
+ // src/sessions/agents/claude/settings.ts
4192
4698
  function planHookCommand(nodePath = process.execPath, plansDir = HOOK_PLANS_DIR) {
4193
4699
  return nodeHookCommand(planHookScript(plansDir), nodePath);
4194
4700
  }
4195
- function headlineHookCommand(nodePath = process.execPath) {
4196
- return nodeHookCommand(headlineHookScript(), nodePath);
4701
+ function taskHookCommand(nodePath = process.execPath, plansDir = HOOK_PLANS_DIR) {
4702
+ return nodeHookCommand(taskHookScript(plansDir), nodePath);
4197
4703
  }
4198
4704
  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`;
4705
+ return recordPullRequestCommand(pullRequestHookScript(), nodePath);
4204
4706
  }
4205
4707
  var STATE_HOOK_TIMEOUT_SECONDS = 5;
4206
4708
  var PLAN_HOOK_TIMEOUT_SECONDS = 10;
4207
- var HEADLINE_HOOK_TIMEOUT_SECONDS = 5;
4709
+ var TASK_HOOK_TIMEOUT_SECONDS = 10;
4208
4710
  var PULL_REQUEST_HOOK_TIMEOUT_SECONDS = 5;
4209
4711
  function claudeSettings() {
4210
4712
  const run = (command, timeout) => ({ type: "command", command, timeout });
@@ -4216,38 +4718,43 @@ function claudeSettings() {
4216
4718
  matcher: "ExitPlanMode",
4217
4719
  hooks: [run(planHookCommand(), PLAN_HOOK_TIMEOUT_SECONDS)]
4218
4720
  },
4721
+ {
4722
+ matcher: TASK_TOOLS.join("|"),
4723
+ hooks: [run(taskHookCommand(), TASK_HOOK_TIMEOUT_SECONDS)]
4724
+ },
4219
4725
  {
4220
4726
  matcher: "Bash",
4221
4727
  hooks: [run(pullRequestHookCommand(), PULL_REQUEST_HOOK_TIMEOUT_SECONDS)]
4222
4728
  }
4223
4729
  ],
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
- }
4730
+ // Working from the moment a prompt is sent; waiting when it needs the user; done when a turn ends.
4731
+ UserPromptSubmit: [{ hooks: [report("working")] }],
4732
+ // The plan is stored while it waits for approval, so it shows before the user decides.
4733
+ PermissionRequest: [
4734
+ { matcher: "*", hooks: [report("waiting")] },
4735
+ { matcher: "ExitPlanMode", hooks: [run(planHookCommand(), PLAN_HOOK_TIMEOUT_SECONDS)] }
4229
4736
  ],
4230
- PermissionRequest: [{ matcher: "*", hooks: [report("waiting")] }],
4231
4737
  Stop: [{ hooks: [report("done")] }],
4232
4738
  StopFailure: [{ hooks: [report("waiting")] }]
4233
4739
  }
4234
4740
  };
4235
4741
  }
4236
4742
 
4237
- // src/sessions/agents/claude-hooks.ts
4743
+ // src/sessions/agents/claude/hooks.ts
4238
4744
  var execFileAsync = promisify4(execFile4);
4239
- var CLAUDE_HOOKS_DIR = join9(homedir6(), ".bondsmith", "hooks");
4745
+ var CLAUDE_HOOKS_DIR = join11(homedir7(), ".bondsmith", "hooks");
4240
4746
  var AGENT_HOOKS_ENV_VAR = "BONDSMITH_AGENT_HOOKS";
4241
4747
  var HOOKS_OFF_VALUES = /^(off|0|false|no)$/i;
4242
4748
  var CLAUDE_BINARY = "claude";
4243
- var LEADING_CLAUDE = new RegExp(`^(\\s*)${CLAUDE_BINARY}(?=\\s|$)`);
4749
+ var LEADING_CLAUDE = new RegExp(
4750
+ `^(\\s*(?:${ENV_ASSIGNMENT_WORD}\\s+)*)${CLAUDE_BINARY}(?=\\s|$)`
4751
+ );
4244
4752
  var SETTINGS_FLAG = "--settings";
4245
4753
  var SUPPORT_PROBE_TIMEOUT_MS = 1e4;
4246
4754
  var SUPPORT_RETRY_AFTER_MS = 6e4;
4247
4755
  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}=`));
4756
+ if (leadingBinary(command) !== CLAUDE_BINARY) return false;
4757
+ return !command.trim().split(/\s+/).some((word) => word === SETTINGS_FLAG || word.startsWith(`${SETTINGS_FLAG}=`));
4251
4758
  }
4252
4759
  async function probeSettingsSupport() {
4253
4760
  try {
@@ -4288,14 +4795,14 @@ async function prepareAgentCommand(command, {
4288
4795
  if (HOOKS_OFF_VALUES.test(env[AGENT_HOOKS_ENV_VAR] ?? "")) return command;
4289
4796
  if (!isPlainClaudeCommand(command)) return command;
4290
4797
  if (!await supportsSettings()) return command;
4291
- const file = join9(hooksDir, "claude-settings.json");
4798
+ const file = join11(hooksDir, "claude-settings.json");
4292
4799
  const staging = `${file}.${randomUUID2()}.tmp`;
4293
4800
  try {
4294
4801
  await mkdir4(hooksDir, { recursive: true });
4295
4802
  await writeFile4(staging, JSON.stringify(claudeSettings(), null, " "));
4296
4803
  await rename4(staging, file);
4297
4804
  } catch (error2) {
4298
- await rm4(staging, { force: true }).catch(() => void 0);
4805
+ await rm5(staging, { force: true }).catch(() => void 0);
4299
4806
  console.error(
4300
4807
  "could not write the Claude Code hook settings; starting claude without them",
4301
4808
  error2
@@ -4308,6 +4815,29 @@ async function prepareAgentCommand(command, {
4308
4815
  );
4309
4816
  }
4310
4817
 
4818
+ // src/sessions/agents/claude/adapter.ts
4819
+ var claudeAdapter = {
4820
+ id: "claude",
4821
+ matches: (command) => leadingBinary(command) === "claude",
4822
+ prepareCommand: (command) => prepareAgentCommand(command)
4823
+ };
4824
+
4825
+ // src/sessions/agents/registry.ts
4826
+ var ADAPTERS = {
4827
+ claude: claudeAdapter,
4828
+ codex: plainAdapter("codex"),
4829
+ opencode: plainAdapter("opencode")
4830
+ };
4831
+ function adapterFor(command) {
4832
+ return Object.values(ADAPTERS).find((adapter) => adapter.matches(command));
4833
+ }
4834
+ function agentIdFor(command) {
4835
+ return adapterFor(command)?.id ?? null;
4836
+ }
4837
+ async function prepareCommand(command) {
4838
+ return await adapterFor(command)?.prepareCommand(command) ?? command;
4839
+ }
4840
+
4311
4841
  // src/sessions/git/start-point.ts
4312
4842
  var WorktreeBaseBranchNotFoundError = class extends Error {
4313
4843
  constructor(branch) {
@@ -4333,13 +4863,13 @@ async function resolveWorktreeStartPoint(sourceCwd, baseBranch) {
4333
4863
 
4334
4864
  // src/sessions/worktree-setup/copy-files.ts
4335
4865
  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";
4866
+ import { copyFile, glob, lstat as lstat4, mkdir as mkdir5, realpath as realpath5 } from "fs/promises";
4867
+ import { dirname as dirname5, isAbsolute as isAbsolute4, join as join12, relative, sep as sep3 } from "path";
4338
4868
  var GIT_EXIT_NONE_IGNORED = 1;
4339
4869
  var TRACKED_CHECK_BATCH = 200;
4340
4870
  function isInside(root, path) {
4341
4871
  const rel = relative(root, path);
4342
- return rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute3(rel);
4872
+ return rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute4(rel);
4343
4873
  }
4344
4874
  async function ignoredPaths(worktree, paths) {
4345
4875
  if (paths.length === 0) return /* @__PURE__ */ new Set();
@@ -4368,16 +4898,16 @@ async function trackedPaths(worktree, paths) {
4368
4898
  }
4369
4899
  return tracked;
4370
4900
  }
4371
- async function copyFilesIntoWorktree(sourceRoot, worktreeRoot, globs) {
4901
+ async function copyFilesIntoWorktree(sourceRoot, worktreeRoot2, globs) {
4372
4902
  const result = { copied: [], failed: 0, skipped: [] };
4373
4903
  const patterns = globs.filter(isSafeCopyGlob);
4374
4904
  if (patterns.length === 0) return result;
4375
4905
  try {
4376
- const source = await realpath2(sourceRoot);
4377
- const worktree = await realpath2(worktreeRoot);
4906
+ const source = await realpath5(sourceRoot);
4907
+ const worktree = await realpath5(worktreeRoot2);
4378
4908
  const candidates = [];
4379
4909
  for await (const match of glob(patterns, { cwd: source })) {
4380
- if (match.split(sep2).includes(".git")) continue;
4910
+ if (match.split(sep3).includes(".git")) continue;
4381
4911
  try {
4382
4912
  if (await isCopyCandidate(source, worktree, match)) candidates.push(match);
4383
4913
  } catch (error2) {
@@ -4417,11 +4947,11 @@ async function copyFilesIntoWorktree(sourceRoot, worktreeRoot, globs) {
4417
4947
  return result;
4418
4948
  }
4419
4949
  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;
4950
+ const from = join12(source, match);
4951
+ if (!(await lstat4(from)).isFile()) return false;
4952
+ if (!isInside(source, await realpath5(from))) return false;
4423
4953
  try {
4424
- await lstat3(join10(worktree, match));
4954
+ await lstat4(join12(worktree, match));
4425
4955
  return false;
4426
4956
  } catch (error2) {
4427
4957
  if (error2.code === "ENOENT") return true;
@@ -4429,11 +4959,11 @@ async function isCopyCandidate(source, worktree, match) {
4429
4959
  }
4430
4960
  }
4431
4961
  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;
4962
+ const to = join12(worktree, match);
4963
+ await mkdir5(dirname5(to), { recursive: true });
4964
+ if (!isInside(worktree, await realpath5(dirname5(to)))) return false;
4435
4965
  try {
4436
- await copyFile(join10(source, match), to, constants4.COPYFILE_EXCL);
4966
+ await copyFile(join12(source, match), to, constants4.COPYFILE_EXCL);
4437
4967
  } catch (error2) {
4438
4968
  if (error2.code === "EEXIST") return false;
4439
4969
  throw error2;
@@ -4448,9 +4978,6 @@ var InvalidCwdError = class extends Error {
4448
4978
  this.name = "InvalidCwdError";
4449
4979
  }
4450
4980
  };
4451
- function worktreePathFor(sessionId) {
4452
- return join11(homedir7(), ".bondsmith", "worktrees", sessionId);
4453
- }
4454
4981
  async function createTmuxSession(input) {
4455
4982
  const sourceCwd = expandHome(input.cwd);
4456
4983
  let cwd = sourceCwd;
@@ -4481,7 +5008,7 @@ async function createTmuxSession(input) {
4481
5008
  const tmuxSessionName = tmuxSessionNameFor(input.id);
4482
5009
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
4483
5010
  const baseline = await captureBaseline(cwd, { freshCheckout: worktreeOf !== null });
4484
- const agentCommand = await prepareAgentCommand(input.command);
5011
+ const agentCommand = await prepareCommand(input.command);
4485
5012
  const setupCommand = worktreeOf ? input.worktreeSetup?.setupCommand?.trim() : void 0;
4486
5013
  const launchCommand = setupCommand ? wrapCommandWithSetup({ setupCommand, command: agentCommand, tmuxSessionName }) : agentCommand;
4487
5014
  await runTmux(
@@ -4530,6 +5057,7 @@ async function createTmuxSession(input) {
4530
5057
  id: input.id,
4531
5058
  title: input.title,
4532
5059
  command: input.command,
5060
+ agent: agentIdFor(input.command),
4533
5061
  cwd,
4534
5062
  tmuxSessionName,
4535
5063
  status: "running",
@@ -4566,6 +5094,7 @@ function parseTmuxSessions(output) {
4566
5094
  id,
4567
5095
  title,
4568
5096
  command,
5097
+ agent: agentIdFor(command),
4569
5098
  cwd,
4570
5099
  tmuxSessionName: sessionName,
4571
5100
  status: "running",
@@ -4649,6 +5178,9 @@ var SessionManager = class {
4649
5178
  for (const [id, baseline] of discovered.baselines) {
4650
5179
  this.baselines.set(id, baseline);
4651
5180
  }
5181
+ sweepOrphanedHookPlanFiles(new Set(this.sessions.keys())).catch(
5182
+ (error2) => console.error("Failed to sweep stored plans of ended sessions", error2)
5183
+ );
4652
5184
  }
4653
5185
  list() {
4654
5186
  return Array.from(this.sessions.values());
@@ -4744,7 +5276,6 @@ ${error2.message}
4744
5276
  agentState: resolveSessionAttention(report, enriched.lastActivityAt, {
4745
5277
  acknowledgedAtMs: this.acknowledgements.get(session.id) ?? null
4746
5278
  }),
4747
- headline: report?.headline ?? null,
4748
5279
  ...report?.pullRequestUrls?.length && { pullRequestUrls: report.pullRequestUrls },
4749
5280
  ...report?.setupFailed && { worktreeSetupFailed: true },
4750
5281
  ...report?.setupSkipped?.length && { worktreeSetupSkipped: report.setupSkipped }
@@ -4767,12 +5298,15 @@ ${error2.message}
4767
5298
  this.sessions.delete(session.id);
4768
5299
  this.baselines.delete(session.id);
4769
5300
  this.acknowledgements.delete(session.id);
5301
+ removeHookPlanFiles(session.id).catch(
5302
+ (error2) => console.error("Failed to remove the session's stored plan", { sessionId: session.id, error: error2 })
5303
+ );
4770
5304
  this.emitUpdated(updated);
4771
5305
  return updated;
4772
5306
  }
4773
5307
  };
4774
5308
  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 ?? []);
5309
+ 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
5310
  }
4777
5311
 
4778
5312
  // src/ws/server.ts
@@ -4896,6 +5430,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
4896
5430
  getWorkspaceDiff,
4897
5431
  readFileRange,
4898
5432
  validateProjectPath,
5433
+ listProjectWorktrees,
5434
+ removeManagedWorktree,
4899
5435
  hostname: () => hostname(),
4900
5436
  daemonFingerprint,
4901
5437
  pendingAuthorizations,
@@ -4939,10 +5475,10 @@ if (isVersionRequest(process.argv.slice(2))) {
4939
5475
  process.exit(0);
4940
5476
  }
4941
5477
  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");
5478
+ const repoRoot = join13(dirname6(fileURLToPath(import.meta.url)), "..", "..", "..");
5479
+ if (!existsSync(join13(repoRoot, "pnpm-workspace.yaml"))) return;
5480
+ const envLocal = join13(repoRoot, ".env.local");
5481
+ const envDefault = join13(repoRoot, ".env");
4946
5482
  loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
4947
5483
  }
4948
5484
  loadRepoRootEnv();
@@ -5001,6 +5537,8 @@ async function start() {
5001
5537
  getWorkspaceDiff,
5002
5538
  readFileRange,
5003
5539
  validateProjectPath,
5540
+ listProjectWorktrees,
5541
+ removeManagedWorktree,
5004
5542
  hostname: hostname2,
5005
5543
  pendingAuthorizations,
5006
5544
  presentPendingAuthorization,