@bondsmith/bondsmithd 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +24 -0
  2. package/dist/index.js +288 -44
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -66,6 +66,30 @@ daemon". Reload the page first; if that doesn't fix it, update the daemon as abo
66
66
  - Keeps tmux itself as the only source of truth — no database. Restarting the daemon rediscovers
67
67
  running sessions from tmux.
68
68
 
69
+ ## Agent state
70
+
71
+ An agent can tell Bondsmith what it is doing by running this from inside its session's pane, usually
72
+ from one of its hooks. `$TMUX_PANE` is set by tmux in every pane, so the command targets the session
73
+ that pane belongs to:
74
+
75
+ ```sh
76
+ tmux set-option -t "$TMUX_PANE" -q @bondsmith-agent-state "waiting:$(date +%s)"
77
+ ```
78
+
79
+ The value is `<state>:<epoch seconds>`, where state is `working`, `waiting` (needs the user) or `done`.
80
+ The daemon reads it on its 5-second tick and shows it on the session. Anything else is ignored, as is
81
+ a report dated in the future. A `waiting` or `done` report is dropped once the pane writes output more
82
+ than 2 seconds after it, since that means the agent moved on; a `working` report is not.
83
+
84
+ An agent with no hook can still get attention by ringing the terminal bell (`printf '\a'`). A bell
85
+ rung in a session nobody has looked at marks it as waiting, whatever its last report said, until tmux
86
+ clears the flag when someone attaches to the session. Agents that can ring the bell for approvals or
87
+ a finished turn work this way with nothing to install.
88
+
89
+ tmux only flags a bell when no client is attached to the session or the bell is in a window other than
90
+ the one the attached client has open. A session whose terminal is open in a browser tab, even a hidden
91
+ one, counts as being looked at, so a bell in it is not flagged; use the hook report for that case.
92
+
69
93
  ## Production connectivity (current status)
70
94
 
71
95
  `bondsmithd` connects outbound to the Bondsmith relay by default (`BONDSMITH_RELAY_ENABLED=true`,
package/dist/index.js CHANGED
@@ -4,18 +4,19 @@
4
4
  import { createServer } from "http";
5
5
  import { existsSync } from "fs";
6
6
  import { hostname as hostname2 } from "os";
7
- import { dirname as dirname4, join as join7 } from "path";
7
+ import { dirname as dirname4, join as join8 } 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
+ var AGENT_STATES = ["working", "waiting", "done"];
12
13
 
13
14
  // ../../packages/local-agent-protocol/src/messages.ts
14
15
  var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
15
16
  var INVALID_MESSAGE_ERROR = "invalid message";
16
17
 
17
18
  // ../../packages/local-agent-protocol/src/protocol-version.ts
18
- var PROTOCOL_VERSION = 4;
19
+ var PROTOCOL_VERSION = 7;
19
20
 
20
21
  // ../../packages/local-agent-protocol/src/project-path.ts
21
22
  var PROJECT_PATH_INVALID_REASONS = [
@@ -59,6 +60,8 @@ var signedConnectionChallengeSchema = z.object({
59
60
  var MAX_INSERT_TEXT_BYTES = 32 * 1024;
60
61
  var MAX_INPUT_CHARS = 16 * 1024;
61
62
  var MAX_WIRE_MESSAGE_BYTES = 256 * 1024;
63
+ var MAX_DAEMON_REPLY_BYTES = 200 * 1024;
64
+ var MAX_RANGE_LINES = 500;
62
65
  var MAX_TERMINAL_DIMENSION = 1e3;
63
66
  var terminalSizeSchema = z2.object({
64
67
  cols: z2.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
@@ -76,7 +79,9 @@ var localSessionSchema = z2.object({
76
79
  repositoryFullName: z2.string().nullable(),
77
80
  lastActivityAt: z2.string().nullable(),
78
81
  changedFileCount: z2.number().int().nonnegative().nullable(),
79
- worktreeOf: z2.string().nullable()
82
+ worktreeOf: z2.string().nullable(),
83
+ // .catch(undefined): a state this version doesn't know must never drop the whole session message.
84
+ agentState: z2.enum(AGENT_STATES).nullable().optional().catch(void 0)
80
85
  });
81
86
  var workspaceFileEntrySchema = z2.object({
82
87
  path: z2.string(),
@@ -92,14 +97,32 @@ var workspaceStatusSchema = z2.object({
92
97
  branch: z2.string().nullable(),
93
98
  // .catch(undefined): a malformed informational field must never drop the whole status message.
94
99
  baseRef: z2.string().nullable().optional().catch(void 0),
95
- files: z2.array(workspaceFileEntrySchema)
100
+ files: z2.array(workspaceFileEntrySchema),
101
+ totalFiles: z2.number().int().nonnegative().optional().catch(void 0)
96
102
  });
97
103
  var workspaceFileDiffSchema = z2.object({
98
104
  path: z2.string(),
99
105
  staged: z2.boolean(),
100
106
  patch: z2.string().nullable(),
101
- binary: z2.boolean()
107
+ binary: z2.boolean(),
108
+ tooLarge: z2.boolean().optional(),
109
+ version: z2.string().optional()
102
110
  });
111
+ var workspaceFileRangeSchema = z2.discriminatedUnion("ok", [
112
+ z2.object({
113
+ ok: z2.literal(true),
114
+ path: z2.string(),
115
+ startLine: z2.number().int().min(1),
116
+ lines: z2.array(z2.string()).max(MAX_RANGE_LINES),
117
+ totalLines: z2.number().int().nonnegative(),
118
+ version: z2.string()
119
+ }),
120
+ z2.object({
121
+ ok: z2.literal(false),
122
+ path: z2.string(),
123
+ reason: z2.enum(["changed", "missing", "binary", "too_large", "not_allowed"])
124
+ })
125
+ ]);
103
126
  var projectPathValidationSchema = z2.discriminatedUnion("valid", [
104
127
  z2.object({ valid: z2.literal(true), path: z2.string() }),
105
128
  z2.object({ valid: z2.literal(false), reason: z2.enum(PROJECT_PATH_INVALID_REASONS) })
@@ -163,6 +186,15 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
163
186
  sessionId: z2.string(),
164
187
  requestId: z2.string().min(1).max(200)
165
188
  }),
189
+ z2.object({
190
+ type: z2.literal("workspace.readFileRange"),
191
+ sessionId: z2.string(),
192
+ path: z2.string().min(1).max(4096),
193
+ startLine: z2.number().int().min(1),
194
+ lineCount: z2.number().int().min(1).max(MAX_RANGE_LINES),
195
+ version: z2.string().min(1).max(100),
196
+ requestId: z2.string().min(1).max(200)
197
+ }),
166
198
  z2.object({
167
199
  type: z2.literal("workspace.getDiff"),
168
200
  sessionId: z2.string(),
@@ -247,6 +279,12 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
247
279
  requestId: z2.string(),
248
280
  status: workspaceStatusSchema
249
281
  }),
282
+ z2.object({
283
+ type: z2.literal("workspace.fileRange"),
284
+ sessionId: z2.string(),
285
+ requestId: z2.string(),
286
+ range: workspaceFileRangeSchema
287
+ }),
250
288
  z2.object({
251
289
  type: z2.literal("workspace.diff"),
252
290
  sessionId: z2.string(),
@@ -367,6 +405,7 @@ var SIGNABLE_ACTIONS = [
367
405
  "session.list",
368
406
  "workspace.getStatus",
369
407
  "workspace.getDiff",
408
+ "workspace.readFileRange",
370
409
  "project.validatePath"
371
410
  ];
372
411
  var envelopeSchema = z3.object({
@@ -951,7 +990,7 @@ import { sign } from "crypto";
951
990
  import WebSocket from "ws";
952
991
 
953
992
  // src/version.ts
954
- var DAEMON_VERSION = true ? "0.1.6" : "dev";
993
+ var DAEMON_VERSION = true ? "0.1.8" : "dev";
955
994
  var VERSION_FLAGS = ["--version", "-v"];
956
995
  function versionLine() {
957
996
  return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
@@ -1012,9 +1051,9 @@ async function sendKeysLiteral(tmuxSessionName, text) {
1012
1051
  async function capturePane(tmuxSessionName) {
1013
1052
  return runTmux(["capture-pane", "-t", tmuxSessionName, "-p", "-e"]);
1014
1053
  }
1015
- async function getPaneActivity(tmuxSessionName) {
1054
+ async function getSessionActivity(tmuxSessionName) {
1016
1055
  try {
1017
- const out = (await runTmux(["display-message", "-p", "-t", tmuxSessionName, "#{pane_activity}"])).trim();
1056
+ const out = (await runTmux(["display-message", "-p", "-t", tmuxSessionName, "#{window_activity}"])).trim();
1018
1057
  const seconds = Number(out);
1019
1058
  if (!out || Number.isNaN(seconds)) return null;
1020
1059
  return new Date(seconds * 1e3).toISOString();
@@ -1375,12 +1414,42 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
1375
1414
  function error(message, requestId) {
1376
1415
  return { type: "error", message, requestId };
1377
1416
  }
1417
+ function fitDiffReply(reply) {
1418
+ const { patch, tooLarge } = reply.diff;
1419
+ const fits = (patch === null || patch.length <= MAX_DAEMON_REPLY_BYTES) && Buffer.byteLength(JSON.stringify(reply)) <= MAX_DAEMON_REPLY_BYTES;
1420
+ if (tooLarge || fits) return reply;
1421
+ return { ...reply, diff: { ...reply.diff, patch: null, tooLarge: true } };
1422
+ }
1423
+ function fitStatusReply(reply) {
1424
+ if (Buffer.byteLength(JSON.stringify(reply)) <= MAX_DAEMON_REPLY_BYTES) return reply;
1425
+ const { files } = reply.status;
1426
+ const bare = { ...reply, status: { ...reply.status, files: [], totalFiles: files.length } };
1427
+ let budget = MAX_DAEMON_REPLY_BYTES - Buffer.byteLength(JSON.stringify(bare));
1428
+ const kept = [];
1429
+ for (const file of files) {
1430
+ const cost = Buffer.byteLength(JSON.stringify(file)) + 1;
1431
+ if (cost > budget) break;
1432
+ budget -= cost;
1433
+ kept.push(file);
1434
+ }
1435
+ return { ...bare, status: { ...bare.status, files: kept } };
1436
+ }
1378
1437
  function errorMessageOf(err, fallback) {
1379
1438
  return err instanceof Error ? err.message : fallback;
1380
1439
  }
1381
1440
  function requestIdOf(message) {
1382
1441
  return "requestId" in message ? message.requestId : void 0;
1383
1442
  }
1443
+ function stringRequestId(value) {
1444
+ if (typeof value !== "object" || value === null || !("requestId" in value)) return void 0;
1445
+ const { requestId } = value;
1446
+ return typeof requestId === "string" && requestId !== "" ? requestId.slice(0, 200) : void 0;
1447
+ }
1448
+ function requestIdOfUnparsed(raw) {
1449
+ const envelope = typeof raw === "object" && raw !== null && "envelope" in raw ? raw.envelope : void 0;
1450
+ const payload = typeof envelope === "object" && envelope !== null && "payload" in envelope ? envelope.payload : void 0;
1451
+ return stringRequestId(payload) ?? stringRequestId(raw);
1452
+ }
1384
1453
  function getSessionOrError(sessionManager, conn, sessionId, requestId) {
1385
1454
  const session = sessionManager.get(sessionId);
1386
1455
  if (!session) conn.send(error("unknown session", requestId));
@@ -1392,7 +1461,7 @@ async function handleBrowserMessage(conn, raw, deps) {
1392
1461
  const { envelope } = signedCommand.data;
1393
1462
  const innerMessage = browserMessageSchema.safeParse(envelope.payload);
1394
1463
  if (!innerMessage.success || innerMessage.data.type !== envelope.action) {
1395
- conn.send(error(INVALID_MESSAGE_ERROR));
1464
+ conn.send(error(INVALID_MESSAGE_ERROR, requestIdOfUnparsed(raw)));
1396
1465
  return;
1397
1466
  }
1398
1467
  const admitted = await conn.admitInOrder(async () => {
@@ -1418,7 +1487,7 @@ async function handleBrowserMessage(conn, raw, deps) {
1418
1487
  }
1419
1488
  const parsed = browserMessageSchema.safeParse(raw);
1420
1489
  if (!parsed.success) {
1421
- conn.send(error(INVALID_MESSAGE_ERROR));
1490
+ conn.send(error(INVALID_MESSAGE_ERROR, requestIdOfUnparsed(raw)));
1422
1491
  return;
1423
1492
  }
1424
1493
  if (parsed.data.type === "connection.authenticate") {
@@ -1446,6 +1515,7 @@ async function dispatch(conn, message, {
1446
1515
  broadcast,
1447
1516
  getWorkspaceStatus: getWorkspaceStatus2,
1448
1517
  getWorkspaceDiff: getWorkspaceDiff2,
1518
+ readFileRange: readFileRange2,
1449
1519
  validateProjectPath: validateProjectPath2,
1450
1520
  hostname: hostname3,
1451
1521
  daemonFingerprint,
@@ -1581,12 +1651,14 @@ async function dispatch(conn, message, {
1581
1651
  session.cwd,
1582
1652
  sessionManager.getBaseline(session.id)
1583
1653
  );
1584
- conn.send({
1585
- type: "workspace.status",
1586
- sessionId: message.sessionId,
1587
- requestId: message.requestId,
1588
- status
1589
- });
1654
+ conn.send(
1655
+ fitStatusReply({
1656
+ type: "workspace.status",
1657
+ sessionId: message.sessionId,
1658
+ requestId: message.requestId,
1659
+ status
1660
+ })
1661
+ );
1590
1662
  } catch (err) {
1591
1663
  conn.send(error(errorMessageOf(err, "failed to read workspace status"), message.requestId));
1592
1664
  }
@@ -1602,14 +1674,38 @@ async function dispatch(conn, message, {
1602
1674
  message.staged,
1603
1675
  sessionManager.getBaseline(session.id)
1604
1676
  );
1677
+ conn.send(
1678
+ fitDiffReply({
1679
+ type: "workspace.diff",
1680
+ sessionId: message.sessionId,
1681
+ requestId: message.requestId,
1682
+ diff
1683
+ })
1684
+ );
1685
+ } catch (err) {
1686
+ conn.send(error(errorMessageOf(err, "failed to read workspace diff"), message.requestId));
1687
+ }
1688
+ return;
1689
+ }
1690
+ case "workspace.readFileRange": {
1691
+ const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
1692
+ if (!session) return;
1693
+ try {
1694
+ const range = await readFileRange2(
1695
+ session.cwd,
1696
+ message.path,
1697
+ message.startLine,
1698
+ message.lineCount,
1699
+ message.version
1700
+ );
1605
1701
  conn.send({
1606
- type: "workspace.diff",
1702
+ type: "workspace.fileRange",
1607
1703
  sessionId: message.sessionId,
1608
1704
  requestId: message.requestId,
1609
- diff
1705
+ range
1610
1706
  });
1611
1707
  } catch (err) {
1612
- conn.send(error(errorMessageOf(err, "failed to read workspace diff"), message.requestId));
1708
+ conn.send(error(errorMessageOf(err, "failed to read file range"), message.requestId));
1613
1709
  }
1614
1710
  return;
1615
1711
  }
@@ -2549,9 +2645,99 @@ function presentPendingAuthorizationOnTerminal(store) {
2549
2645
  };
2550
2646
  }
2551
2647
 
2648
+ // src/sessions/files/read-range.ts
2649
+ import { constants } from "fs";
2650
+ import { lstat, open, realpath, stat as stat2 } from "fs/promises";
2651
+ import { isAbsolute as isAbsolute2, join as join5, sep } from "path";
2652
+ var MAX_RANGE_FILE_BYTES = 2 * 1024 * 1024;
2653
+ var REPLY_ENVELOPE_BYTES = 8 * 1024;
2654
+ var BINARY_SNIFF_BYTES = 8e3;
2655
+ function fileVersion(stats) {
2656
+ return `${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}:${stats.ino}`;
2657
+ }
2658
+ async function locateInside(cwd, path) {
2659
+ if (isAbsolute2(path)) return { refused: "not_allowed" };
2660
+ try {
2661
+ const root = await realpath(cwd);
2662
+ const lexical = join5(root, path);
2663
+ if ((await lstat(lexical)).isSymbolicLink()) return { refused: "not_allowed" };
2664
+ const real = await realpath(lexical);
2665
+ return real === root || real.startsWith(root + sep) ? { found: real } : { refused: "missing" };
2666
+ } catch {
2667
+ return { refused: "missing" };
2668
+ }
2669
+ }
2670
+ async function readExactly(handle, size) {
2671
+ const buffer = Buffer.allocUnsafe(size + 1);
2672
+ let filled = 0;
2673
+ while (filled < buffer.length) {
2674
+ const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
2675
+ if (bytesRead === 0) break;
2676
+ filled += bytesRead;
2677
+ }
2678
+ return filled === size ? buffer.subarray(0, size) : void 0;
2679
+ }
2680
+ async function currentFileVersion(cwd, path) {
2681
+ const located = await locateInside(cwd, path);
2682
+ if (!("found" in located)) return void 0;
2683
+ try {
2684
+ return fileVersion(await stat2(located.found));
2685
+ } catch {
2686
+ return void 0;
2687
+ }
2688
+ }
2689
+ function splitLines(text) {
2690
+ if (text === "") return [];
2691
+ const lines = text.split("\n");
2692
+ if (lines[lines.length - 1] === "") lines.pop();
2693
+ return lines.map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
2694
+ }
2695
+ async function readFileRange(cwd, path, startLine, lineCount, version) {
2696
+ const refuse = (reason) => ({ ok: false, path, reason });
2697
+ const located = await locateInside(cwd, path);
2698
+ if (!("found" in located)) return refuse(located.refused);
2699
+ let content;
2700
+ let handle;
2701
+ try {
2702
+ handle = await open(
2703
+ located.found,
2704
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK
2705
+ );
2706
+ const before = await handle.stat();
2707
+ if (!before.isFile()) return refuse("not_allowed");
2708
+ if (fileVersion(before) !== version) return refuse("changed");
2709
+ if (before.size > MAX_RANGE_FILE_BYTES) return refuse("too_large");
2710
+ const bytes = await readExactly(handle, before.size);
2711
+ if (bytes === void 0 || fileVersion(await handle.stat()) !== version) {
2712
+ return refuse("changed");
2713
+ }
2714
+ content = bytes;
2715
+ } catch {
2716
+ return refuse("missing");
2717
+ } finally {
2718
+ await handle?.close();
2719
+ }
2720
+ if (content.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return refuse("binary");
2721
+ const lines = splitLines(content.toString("utf8"));
2722
+ const page = [];
2723
+ let budget = MAX_DAEMON_REPLY_BYTES - REPLY_ENVELOPE_BYTES;
2724
+ const wanted = Math.min(lineCount, MAX_RANGE_LINES);
2725
+ for (let index = startLine - 1; index < lines.length && page.length < wanted; index++) {
2726
+ const line = lines[index];
2727
+ const cost = Buffer.byteLength(JSON.stringify(line)) + 1;
2728
+ if (cost > budget) {
2729
+ if (page.length === 0) return refuse("too_large");
2730
+ break;
2731
+ }
2732
+ budget -= cost;
2733
+ page.push(line);
2734
+ }
2735
+ return { ok: true, path, startLine, lines: page, totalLines: lines.length, version };
2736
+ }
2737
+
2552
2738
  // src/sessions/git/baseline.ts
2553
- import { lstat } from "fs/promises";
2554
- import { join as join5 } from "path";
2739
+ import { lstat as lstat2 } from "fs/promises";
2740
+ import { join as join6 } from "path";
2555
2741
 
2556
2742
  // src/sessions/git/base.ts
2557
2743
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
@@ -2592,7 +2778,7 @@ var MAX_PREEXISTING_FILES = 200;
2592
2778
  var DELETED = "deleted";
2593
2779
  async function fingerprintFile(cwd, path) {
2594
2780
  try {
2595
- const stats = await lstat(join5(cwd, path));
2781
+ const stats = await lstat2(join6(cwd, path));
2596
2782
  return `${stats.size}:${Math.trunc(stats.mtimeMs)}`;
2597
2783
  } catch {
2598
2784
  return DELETED;
@@ -2827,7 +3013,7 @@ async function enrichSessionWithGitMetadata(session, baseline) {
2827
3013
  getCurrentBranch(session.cwd),
2828
3014
  getRepositoryFullName(session.cwd),
2829
3015
  getWorkspaceStatus(session.cwd, baseline),
2830
- getPaneActivity(session.tmuxSessionName)
3016
+ getSessionActivity(session.tmuxSessionName)
2831
3017
  ]);
2832
3018
  const changedFileCount = status.isGitRepo ? new Set(status.files.map((f) => f.path)).size : null;
2833
3019
  return {
@@ -2849,8 +3035,12 @@ async function isUntracked(cwd, path) {
2849
3035
  const out = await runGit(["ls-files", "--others", "--exclude-standard", "--", path], cwd);
2850
3036
  return out.trim() !== "";
2851
3037
  }
3038
+ function isOutputOverflow(err) {
3039
+ return err?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
3040
+ }
2852
3041
  async function getWorkspaceDiff(cwd, path, staged, baseline) {
2853
3042
  const base = await resolveBaseFor(cwd, baseline);
3043
+ const version = await currentFileVersion(cwd, path);
2854
3044
  const pathspec = await withRenameSource(cwd, base.sha, path);
2855
3045
  const numstat = await runGitDiff(
2856
3046
  ["--relative", "--numstat", "-M", base.sha, "--", ...pathspec],
@@ -2860,13 +3050,27 @@ async function getWorkspaceDiff(cwd, path, staged, baseline) {
2860
3050
  if (binary) {
2861
3051
  return { path, staged, patch: null, binary: true };
2862
3052
  }
2863
- let patch = await runGitDiff(["--relative", "-M", base.sha, "--", ...pathspec], cwd);
2864
- if (patch.trim() === "" && await isUntracked(cwd, path)) {
2865
- patch = await runGitDiffAllowingExitCodes(["--no-index", "--", "/dev/null", path], cwd, [
2866
- 1
2867
- ]).catch(() => "");
3053
+ try {
3054
+ let patch = await runGitDiff(["--relative", "-M", base.sha, "--", ...pathspec], cwd);
3055
+ if (patch.trim() === "" && await isUntracked(cwd, path)) {
3056
+ patch = await runGitDiffAllowingExitCodes(["--no-index", "--", "/dev/null", path], cwd, [
3057
+ 1
3058
+ ]).catch((err) => {
3059
+ if (isOutputOverflow(err)) throw err;
3060
+ return "";
3061
+ });
3062
+ }
3063
+ return {
3064
+ path,
3065
+ staged,
3066
+ patch: patch || null,
3067
+ binary: false,
3068
+ ...version === void 0 ? {} : { version }
3069
+ };
3070
+ } catch (err) {
3071
+ if (isOutputOverflow(err)) return { path, staged, patch: null, binary: false, tooLarge: true };
3072
+ throw err;
2868
3073
  }
2869
- return { path, staged, patch: patch || null, binary: false };
2870
3074
  }
2871
3075
 
2872
3076
  // src/ws/server.ts
@@ -3054,10 +3258,42 @@ async function removeWorktree(sourceCwd, worktreePath) {
3054
3258
  }
3055
3259
  }
3056
3260
 
3261
+ // src/sessions/tmux/agent-state.ts
3262
+ var LIVE_SESSION_REPORTS_FORMAT = "#{session_name} #{@bondsmith-agent-state} #{window_bell_flag}";
3263
+ var AGENT_STATE_OUTPUT_GRACE_MS = 2e3;
3264
+ var FUTURE_REPORT_ALLOWANCE_MS = 2e3;
3265
+ var SETTLED_STATES = /* @__PURE__ */ new Set(["waiting", "done"]);
3266
+ function isAgentState(value) {
3267
+ return AGENT_STATES.includes(value);
3268
+ }
3269
+ function parseLiveSessionReports(output) {
3270
+ const reports = /* @__PURE__ */ new Map();
3271
+ for (const line of output.split("\n")) {
3272
+ const [sessionName, agentState = "", bell = ""] = line.split(" ");
3273
+ if (sessionName) reports.set(sessionName, { agentState, bell: bell === "1" });
3274
+ }
3275
+ return reports;
3276
+ }
3277
+ function resolveAgentState(raw, lastActivityAt, nowMs = Date.now()) {
3278
+ if (!raw) return null;
3279
+ const [state, epoch] = raw.split(":");
3280
+ if (!state || !isAgentState(state) || !epoch || !/^\d+$/.test(epoch)) return null;
3281
+ const reportedAtMs = Number(epoch) * 1e3;
3282
+ if (reportedAtMs > nowMs + FUTURE_REPORT_ALLOWANCE_MS) return null;
3283
+ if (SETTLED_STATES.has(state) && lastActivityAt) {
3284
+ if (Date.parse(lastActivityAt) > reportedAtMs + AGENT_STATE_OUTPUT_GRACE_MS) return null;
3285
+ }
3286
+ return state;
3287
+ }
3288
+ function resolveSessionAttention(report, lastActivityAt, nowMs) {
3289
+ if (report?.bell) return "waiting";
3290
+ return resolveAgentState(report?.agentState, lastActivityAt, nowMs);
3291
+ }
3292
+
3057
3293
  // src/sessions/tmux/create.ts
3058
3294
  import { access } from "fs/promises";
3059
3295
  import { homedir as homedir5 } from "os";
3060
- import { join as join6 } from "path";
3296
+ import { join as join7 } from "path";
3061
3297
  var InvalidCwdError = class extends Error {
3062
3298
  constructor(cwd) {
3063
3299
  super(`working directory does not exist: ${cwd}`);
@@ -3065,7 +3301,7 @@ var InvalidCwdError = class extends Error {
3065
3301
  }
3066
3302
  };
3067
3303
  function worktreePathFor(sessionId) {
3068
- return join6(homedir5(), ".bondsmith", "worktrees", sessionId);
3304
+ return join7(homedir5(), ".bondsmith", "worktrees", sessionId);
3069
3305
  }
3070
3306
  async function createTmuxSession(input) {
3071
3307
  const sourceCwd = expandHome(input.cwd);
@@ -3277,13 +3513,12 @@ var SessionManager = class {
3277
3513
  * at create/discover — otherwise the associated-work lookup (open PR whose
3278
3514
  * head = session.branch) stays pinned to a branch the developer already
3279
3515
  * left. `session.updated` is only emitted when something actually changed,
3280
- * so an idle session doesn't broadcast every tick.
3516
+ * so an idle session doesn't broadcast every tick. The same tick re-reads each session's agent report.
3281
3517
  */
3282
3518
  async reconcile() {
3283
- let liveSessionNames = /* @__PURE__ */ new Set();
3519
+ let agentReports = /* @__PURE__ */ new Map();
3284
3520
  try {
3285
- const output = await listTmuxSessions("#{session_name}");
3286
- liveSessionNames = new Set(output.split("\n").filter((name) => name.length > 0));
3521
+ agentReports = parseLiveSessionReports(await listTmuxSessions(LIVE_SESSION_REPORTS_FORMAT));
3287
3522
  } catch (error2) {
3288
3523
  if (error2 instanceof TmuxNotFoundError) console.error(`
3289
3524
  ${error2.message}
@@ -3297,16 +3532,23 @@ ${error2.message}
3297
3532
  }
3298
3533
  await Promise.all(
3299
3534
  Array.from(this.sessions.values()).map(async (session) => {
3300
- if (!liveSessionNames.has(session.tmuxSessionName)) {
3535
+ if (!agentReports.has(session.tmuxSessionName)) {
3301
3536
  this.removeAsExited(session);
3302
3537
  return;
3303
3538
  }
3304
3539
  try {
3305
- const updated = await enrichSessionWithGitMetadata(
3540
+ const enriched = await enrichSessionWithGitMetadata(
3306
3541
  session,
3307
3542
  this.baselines.get(session.id)
3308
3543
  );
3309
- if (!gitSnapshotEquals(session, updated)) {
3544
+ const updated = {
3545
+ ...enriched,
3546
+ agentState: resolveSessionAttention(
3547
+ agentReports.get(session.tmuxSessionName),
3548
+ enriched.lastActivityAt
3549
+ )
3550
+ };
3551
+ if (!snapshotEquals(session, updated)) {
3310
3552
  this.sessions.set(session.id, updated);
3311
3553
  this.emitUpdated(updated);
3312
3554
  }
@@ -3327,8 +3569,8 @@ ${error2.message}
3327
3569
  return updated;
3328
3570
  }
3329
3571
  };
3330
- function gitSnapshotEquals(a, b) {
3331
- return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt;
3572
+ function snapshotEquals(a, b) {
3573
+ return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt && (a.agentState ?? null) === (b.agentState ?? null);
3332
3574
  }
3333
3575
 
3334
3576
  // src/ws/server.ts
@@ -3443,6 +3685,7 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
3443
3685
  broadcast,
3444
3686
  getWorkspaceStatus,
3445
3687
  getWorkspaceDiff,
3688
+ readFileRange,
3446
3689
  validateProjectPath,
3447
3690
  hostname: () => hostname(),
3448
3691
  daemonFingerprint,
@@ -3483,10 +3726,10 @@ if (isVersionRequest(process.argv.slice(2))) {
3483
3726
  process.exit(0);
3484
3727
  }
3485
3728
  function loadRepoRootEnv() {
3486
- const repoRoot = join7(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
3487
- if (!existsSync(join7(repoRoot, "pnpm-workspace.yaml"))) return;
3488
- const envLocal = join7(repoRoot, ".env.local");
3489
- const envDefault = join7(repoRoot, ".env");
3729
+ const repoRoot = join8(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
3730
+ if (!existsSync(join8(repoRoot, "pnpm-workspace.yaml"))) return;
3731
+ const envLocal = join8(repoRoot, ".env.local");
3732
+ const envDefault = join8(repoRoot, ".env");
3490
3733
  loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
3491
3734
  }
3492
3735
  loadRepoRootEnv();
@@ -3540,6 +3783,7 @@ async function start() {
3540
3783
  ptyAttachments,
3541
3784
  getWorkspaceStatus,
3542
3785
  getWorkspaceDiff,
3786
+ readFileRange,
3543
3787
  validateProjectPath,
3544
3788
  hostname: hostname2,
3545
3789
  pendingAuthorizations,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bondsmith/bondsmithd",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",