@bondsmith/bondsmithd 0.1.7 → 0.1.9

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 +15 -0
  2. package/dist/index.js +324 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -81,6 +81,21 @@ The daemon reads it on its 5-second tick and shows it on the session. Anything e
81
81
  a report dated in the future. A `waiting` or `done` report is dropped once the pane writes output more
82
82
  than 2 seconds after it, since that means the agent moved on; a `working` report is not.
83
83
 
84
+ An agent can also share the plan it is working against, which Bondsmith shows above the session's
85
+ changes. Run this from inside the session's pane once the plan is agreed, and again whenever it changes:
86
+
87
+ ```sh
88
+ tmux set-option -t "$TMUX_PANE" -q @bondsmith-plan "$(cat <<'EOF'
89
+ # What the session is doing
90
+ - step one
91
+ - step two
92
+ EOF
93
+ )"
94
+ ```
95
+
96
+ The value is markdown, cut at 32,768 characters. The daemon reads it only when the browser asks, so
97
+ unlike the state report it has no timestamp and is not dropped when the pane writes output.
98
+
84
99
  An agent with no hook can still get attention by ringing the terminal bell (`printf '\a'`). A bell
85
100
  rung in a session nobody has looked at marks it as waiting, whatever its last report said, until tmux
86
101
  clears the flag when someone attaches to the session. Agents that can ring the bell for approvals or
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
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
@@ -16,7 +16,7 @@ var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
16
16
  var INVALID_MESSAGE_ERROR = "invalid message";
17
17
 
18
18
  // ../../packages/local-agent-protocol/src/protocol-version.ts
19
- var PROTOCOL_VERSION = 4;
19
+ var PROTOCOL_VERSION = 8;
20
20
 
21
21
  // ../../packages/local-agent-protocol/src/project-path.ts
22
22
  var PROJECT_PATH_INVALID_REASONS = [
@@ -60,6 +60,9 @@ var signedConnectionChallengeSchema = z.object({
60
60
  var MAX_INSERT_TEXT_BYTES = 32 * 1024;
61
61
  var MAX_INPUT_CHARS = 16 * 1024;
62
62
  var MAX_WIRE_MESSAGE_BYTES = 256 * 1024;
63
+ var MAX_DAEMON_REPLY_BYTES = 200 * 1024;
64
+ var MAX_RANGE_LINES = 500;
65
+ var MAX_PLAN_CHARS = 32 * 1024;
63
66
  var MAX_TERMINAL_DIMENSION = 1e3;
64
67
  var terminalSizeSchema = z2.object({
65
68
  cols: z2.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
@@ -95,14 +98,32 @@ var workspaceStatusSchema = z2.object({
95
98
  branch: z2.string().nullable(),
96
99
  // .catch(undefined): a malformed informational field must never drop the whole status message.
97
100
  baseRef: z2.string().nullable().optional().catch(void 0),
98
- files: z2.array(workspaceFileEntrySchema)
101
+ files: z2.array(workspaceFileEntrySchema),
102
+ totalFiles: z2.number().int().nonnegative().optional().catch(void 0)
99
103
  });
100
104
  var workspaceFileDiffSchema = z2.object({
101
105
  path: z2.string(),
102
106
  staged: z2.boolean(),
103
107
  patch: z2.string().nullable(),
104
- binary: z2.boolean()
108
+ binary: z2.boolean(),
109
+ tooLarge: z2.boolean().optional(),
110
+ version: z2.string().optional()
105
111
  });
112
+ var workspaceFileRangeSchema = z2.discriminatedUnion("ok", [
113
+ z2.object({
114
+ ok: z2.literal(true),
115
+ path: z2.string(),
116
+ startLine: z2.number().int().min(1),
117
+ lines: z2.array(z2.string()).max(MAX_RANGE_LINES),
118
+ totalLines: z2.number().int().nonnegative(),
119
+ version: z2.string()
120
+ }),
121
+ z2.object({
122
+ ok: z2.literal(false),
123
+ path: z2.string(),
124
+ reason: z2.enum(["changed", "missing", "binary", "too_large", "not_allowed"])
125
+ })
126
+ ]);
106
127
  var projectPathValidationSchema = z2.discriminatedUnion("valid", [
107
128
  z2.object({ valid: z2.literal(true), path: z2.string() }),
108
129
  z2.object({ valid: z2.literal(false), reason: z2.enum(PROJECT_PATH_INVALID_REASONS) })
@@ -166,6 +187,15 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
166
187
  sessionId: z2.string(),
167
188
  requestId: z2.string().min(1).max(200)
168
189
  }),
190
+ z2.object({
191
+ type: z2.literal("workspace.readFileRange"),
192
+ sessionId: z2.string(),
193
+ path: z2.string().min(1).max(4096),
194
+ startLine: z2.number().int().min(1),
195
+ lineCount: z2.number().int().min(1).max(MAX_RANGE_LINES),
196
+ version: z2.string().min(1).max(100),
197
+ requestId: z2.string().min(1).max(200)
198
+ }),
169
199
  z2.object({
170
200
  type: z2.literal("workspace.getDiff"),
171
201
  sessionId: z2.string(),
@@ -173,6 +203,11 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
173
203
  staged: z2.boolean(),
174
204
  requestId: z2.string().min(1).max(200)
175
205
  }),
206
+ z2.object({
207
+ type: z2.literal("workspace.getPlan"),
208
+ sessionId: z2.string(),
209
+ requestId: z2.string().min(1).max(200)
210
+ }),
176
211
  z2.object({
177
212
  type: z2.literal("project.validatePath"),
178
213
  path: z2.string().min(1).max(4096),
@@ -250,12 +285,24 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
250
285
  requestId: z2.string(),
251
286
  status: workspaceStatusSchema
252
287
  }),
288
+ z2.object({
289
+ type: z2.literal("workspace.fileRange"),
290
+ sessionId: z2.string(),
291
+ requestId: z2.string(),
292
+ range: workspaceFileRangeSchema
293
+ }),
253
294
  z2.object({
254
295
  type: z2.literal("workspace.diff"),
255
296
  sessionId: z2.string(),
256
297
  requestId: z2.string(),
257
298
  diff: workspaceFileDiffSchema
258
299
  }),
300
+ z2.object({
301
+ type: z2.literal("workspace.plan"),
302
+ sessionId: z2.string(),
303
+ requestId: z2.string(),
304
+ plan: z2.string().max(MAX_PLAN_CHARS).nullable()
305
+ }),
259
306
  z2.object({
260
307
  type: z2.literal("project.pathValidated"),
261
308
  requestId: z2.string(),
@@ -370,6 +417,8 @@ var SIGNABLE_ACTIONS = [
370
417
  "session.list",
371
418
  "workspace.getStatus",
372
419
  "workspace.getDiff",
420
+ "workspace.readFileRange",
421
+ "workspace.getPlan",
373
422
  "project.validatePath"
374
423
  ];
375
424
  var envelopeSchema = z3.object({
@@ -855,6 +904,9 @@ import { promisify } from "util";
855
904
  var execFile = promisify(execFileCb);
856
905
  var MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
857
906
  var SAFE_DIFF_FLAGS = ["--no-ext-diff", "--no-textconv"];
907
+ function splitNulList(output) {
908
+ return output.split("\0").filter((entry) => entry.length > 0);
909
+ }
858
910
  var GitNotARepoError = class extends Error {
859
911
  constructor() {
860
912
  super("not a git repository");
@@ -954,7 +1006,7 @@ import { sign } from "crypto";
954
1006
  import WebSocket from "ws";
955
1007
 
956
1008
  // src/version.ts
957
- var DAEMON_VERSION = true ? "0.1.7" : "dev";
1009
+ var DAEMON_VERSION = true ? "0.1.9" : "dev";
958
1010
  var VERSION_FLAGS = ["--version", "-v"];
959
1011
  function versionLine() {
960
1012
  return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
@@ -1025,6 +1077,16 @@ async function getSessionActivity(tmuxSessionName) {
1025
1077
  return null;
1026
1078
  }
1027
1079
  }
1080
+ var PLAN_OPTION = "@bondsmith-plan";
1081
+ async function getSessionPlan(tmuxSessionName) {
1082
+ try {
1083
+ const plan = (await runTmux(["show-options", "-qv", "-t", tmuxSessionName, PLAN_OPTION])).trim();
1084
+ return plan ? plan.slice(0, MAX_PLAN_CHARS) : null;
1085
+ } catch (error2) {
1086
+ if (error2 instanceof TmuxNotFoundError) throw error2;
1087
+ return null;
1088
+ }
1089
+ }
1028
1090
  async function listTmuxSessions(formatString) {
1029
1091
  try {
1030
1092
  return await runTmux(["list-sessions", "-F", formatString]);
@@ -1378,12 +1440,42 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
1378
1440
  function error(message, requestId) {
1379
1441
  return { type: "error", message, requestId };
1380
1442
  }
1443
+ function fitDiffReply(reply) {
1444
+ const { patch, tooLarge } = reply.diff;
1445
+ const fits = (patch === null || patch.length <= MAX_DAEMON_REPLY_BYTES) && Buffer.byteLength(JSON.stringify(reply)) <= MAX_DAEMON_REPLY_BYTES;
1446
+ if (tooLarge || fits) return reply;
1447
+ return { ...reply, diff: { ...reply.diff, patch: null, tooLarge: true } };
1448
+ }
1449
+ function fitStatusReply(reply) {
1450
+ if (Buffer.byteLength(JSON.stringify(reply)) <= MAX_DAEMON_REPLY_BYTES) return reply;
1451
+ const { files } = reply.status;
1452
+ const bare = { ...reply, status: { ...reply.status, files: [], totalFiles: files.length } };
1453
+ let budget = MAX_DAEMON_REPLY_BYTES - Buffer.byteLength(JSON.stringify(bare));
1454
+ const kept = [];
1455
+ for (const file of files) {
1456
+ const cost = Buffer.byteLength(JSON.stringify(file)) + 1;
1457
+ if (cost > budget) break;
1458
+ budget -= cost;
1459
+ kept.push(file);
1460
+ }
1461
+ return { ...bare, status: { ...bare.status, files: kept } };
1462
+ }
1381
1463
  function errorMessageOf(err, fallback) {
1382
1464
  return err instanceof Error ? err.message : fallback;
1383
1465
  }
1384
1466
  function requestIdOf(message) {
1385
1467
  return "requestId" in message ? message.requestId : void 0;
1386
1468
  }
1469
+ function stringRequestId(value) {
1470
+ if (typeof value !== "object" || value === null || !("requestId" in value)) return void 0;
1471
+ const { requestId } = value;
1472
+ return typeof requestId === "string" && requestId !== "" ? requestId.slice(0, 200) : void 0;
1473
+ }
1474
+ function requestIdOfUnparsed(raw) {
1475
+ const envelope = typeof raw === "object" && raw !== null && "envelope" in raw ? raw.envelope : void 0;
1476
+ const payload = typeof envelope === "object" && envelope !== null && "payload" in envelope ? envelope.payload : void 0;
1477
+ return stringRequestId(payload) ?? stringRequestId(raw);
1478
+ }
1387
1479
  function getSessionOrError(sessionManager, conn, sessionId, requestId) {
1388
1480
  const session = sessionManager.get(sessionId);
1389
1481
  if (!session) conn.send(error("unknown session", requestId));
@@ -1395,7 +1487,7 @@ async function handleBrowserMessage(conn, raw, deps) {
1395
1487
  const { envelope } = signedCommand.data;
1396
1488
  const innerMessage = browserMessageSchema.safeParse(envelope.payload);
1397
1489
  if (!innerMessage.success || innerMessage.data.type !== envelope.action) {
1398
- conn.send(error(INVALID_MESSAGE_ERROR));
1490
+ conn.send(error(INVALID_MESSAGE_ERROR, requestIdOfUnparsed(raw)));
1399
1491
  return;
1400
1492
  }
1401
1493
  const admitted = await conn.admitInOrder(async () => {
@@ -1421,7 +1513,7 @@ async function handleBrowserMessage(conn, raw, deps) {
1421
1513
  }
1422
1514
  const parsed = browserMessageSchema.safeParse(raw);
1423
1515
  if (!parsed.success) {
1424
- conn.send(error(INVALID_MESSAGE_ERROR));
1516
+ conn.send(error(INVALID_MESSAGE_ERROR, requestIdOfUnparsed(raw)));
1425
1517
  return;
1426
1518
  }
1427
1519
  if (parsed.data.type === "connection.authenticate") {
@@ -1449,6 +1541,7 @@ async function dispatch(conn, message, {
1449
1541
  broadcast,
1450
1542
  getWorkspaceStatus: getWorkspaceStatus2,
1451
1543
  getWorkspaceDiff: getWorkspaceDiff2,
1544
+ readFileRange: readFileRange2,
1452
1545
  validateProjectPath: validateProjectPath2,
1453
1546
  hostname: hostname3,
1454
1547
  daemonFingerprint,
@@ -1584,12 +1677,14 @@ async function dispatch(conn, message, {
1584
1677
  session.cwd,
1585
1678
  sessionManager.getBaseline(session.id)
1586
1679
  );
1587
- conn.send({
1588
- type: "workspace.status",
1589
- sessionId: message.sessionId,
1590
- requestId: message.requestId,
1591
- status
1592
- });
1680
+ conn.send(
1681
+ fitStatusReply({
1682
+ type: "workspace.status",
1683
+ sessionId: message.sessionId,
1684
+ requestId: message.requestId,
1685
+ status
1686
+ })
1687
+ );
1593
1688
  } catch (err) {
1594
1689
  conn.send(error(errorMessageOf(err, "failed to read workspace status"), message.requestId));
1595
1690
  }
@@ -1605,14 +1700,54 @@ async function dispatch(conn, message, {
1605
1700
  message.staged,
1606
1701
  sessionManager.getBaseline(session.id)
1607
1702
  );
1703
+ conn.send(
1704
+ fitDiffReply({
1705
+ type: "workspace.diff",
1706
+ sessionId: message.sessionId,
1707
+ requestId: message.requestId,
1708
+ diff
1709
+ })
1710
+ );
1711
+ } catch (err) {
1712
+ conn.send(error(errorMessageOf(err, "failed to read workspace diff"), message.requestId));
1713
+ }
1714
+ return;
1715
+ }
1716
+ case "workspace.readFileRange": {
1717
+ const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
1718
+ if (!session) return;
1719
+ try {
1720
+ const range = await readFileRange2(
1721
+ session.cwd,
1722
+ message.path,
1723
+ message.startLine,
1724
+ message.lineCount,
1725
+ message.version
1726
+ );
1608
1727
  conn.send({
1609
- type: "workspace.diff",
1728
+ type: "workspace.fileRange",
1610
1729
  sessionId: message.sessionId,
1611
1730
  requestId: message.requestId,
1612
- diff
1731
+ range
1613
1732
  });
1614
1733
  } catch (err) {
1615
- conn.send(error(errorMessageOf(err, "failed to read workspace diff"), message.requestId));
1734
+ conn.send(error(errorMessageOf(err, "failed to read file range"), message.requestId));
1735
+ }
1736
+ return;
1737
+ }
1738
+ case "workspace.getPlan": {
1739
+ const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
1740
+ if (!session) return;
1741
+ try {
1742
+ const plan = await getSessionPlan(session.tmuxSessionName);
1743
+ conn.send({
1744
+ type: "workspace.plan",
1745
+ sessionId: message.sessionId,
1746
+ requestId: message.requestId,
1747
+ plan
1748
+ });
1749
+ } catch (err) {
1750
+ conn.send(error(errorMessageOf(err, "failed to read the session plan"), message.requestId));
1616
1751
  }
1617
1752
  return;
1618
1753
  }
@@ -2552,9 +2687,99 @@ function presentPendingAuthorizationOnTerminal(store) {
2552
2687
  };
2553
2688
  }
2554
2689
 
2690
+ // src/sessions/files/read-range.ts
2691
+ import { constants } from "fs";
2692
+ import { lstat, open, realpath, stat as stat2 } from "fs/promises";
2693
+ import { isAbsolute as isAbsolute2, join as join5, sep } from "path";
2694
+ var MAX_RANGE_FILE_BYTES = 2 * 1024 * 1024;
2695
+ var REPLY_ENVELOPE_BYTES = 8 * 1024;
2696
+ var BINARY_SNIFF_BYTES = 8e3;
2697
+ function fileVersion(stats) {
2698
+ return `${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}:${stats.ino}`;
2699
+ }
2700
+ async function locateInside(cwd, path) {
2701
+ if (isAbsolute2(path)) return { refused: "not_allowed" };
2702
+ try {
2703
+ const root = await realpath(cwd);
2704
+ const lexical = join5(root, path);
2705
+ if ((await lstat(lexical)).isSymbolicLink()) return { refused: "not_allowed" };
2706
+ const real = await realpath(lexical);
2707
+ return real === root || real.startsWith(root + sep) ? { found: real } : { refused: "missing" };
2708
+ } catch {
2709
+ return { refused: "missing" };
2710
+ }
2711
+ }
2712
+ async function readExactly(handle, size) {
2713
+ const buffer = Buffer.allocUnsafe(size + 1);
2714
+ let filled = 0;
2715
+ while (filled < buffer.length) {
2716
+ const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
2717
+ if (bytesRead === 0) break;
2718
+ filled += bytesRead;
2719
+ }
2720
+ return filled === size ? buffer.subarray(0, size) : void 0;
2721
+ }
2722
+ async function currentFileVersion(cwd, path) {
2723
+ const located = await locateInside(cwd, path);
2724
+ if (!("found" in located)) return void 0;
2725
+ try {
2726
+ return fileVersion(await stat2(located.found));
2727
+ } catch {
2728
+ return void 0;
2729
+ }
2730
+ }
2731
+ function splitLines(text) {
2732
+ if (text === "") return [];
2733
+ const lines = text.split("\n");
2734
+ if (lines[lines.length - 1] === "") lines.pop();
2735
+ return lines.map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
2736
+ }
2737
+ async function readFileRange(cwd, path, startLine, lineCount, version) {
2738
+ const refuse = (reason) => ({ ok: false, path, reason });
2739
+ const located = await locateInside(cwd, path);
2740
+ if (!("found" in located)) return refuse(located.refused);
2741
+ let content;
2742
+ let handle;
2743
+ try {
2744
+ handle = await open(
2745
+ located.found,
2746
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK
2747
+ );
2748
+ const before = await handle.stat();
2749
+ if (!before.isFile()) return refuse("not_allowed");
2750
+ if (fileVersion(before) !== version) return refuse("changed");
2751
+ if (before.size > MAX_RANGE_FILE_BYTES) return refuse("too_large");
2752
+ const bytes = await readExactly(handle, before.size);
2753
+ if (bytes === void 0 || fileVersion(await handle.stat()) !== version) {
2754
+ return refuse("changed");
2755
+ }
2756
+ content = bytes;
2757
+ } catch {
2758
+ return refuse("missing");
2759
+ } finally {
2760
+ await handle?.close();
2761
+ }
2762
+ if (content.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return refuse("binary");
2763
+ const lines = splitLines(content.toString("utf8"));
2764
+ const page = [];
2765
+ let budget = MAX_DAEMON_REPLY_BYTES - REPLY_ENVELOPE_BYTES;
2766
+ const wanted = Math.min(lineCount, MAX_RANGE_LINES);
2767
+ for (let index = startLine - 1; index < lines.length && page.length < wanted; index++) {
2768
+ const line = lines[index];
2769
+ const cost = Buffer.byteLength(JSON.stringify(line)) + 1;
2770
+ if (cost > budget) {
2771
+ if (page.length === 0) return refuse("too_large");
2772
+ break;
2773
+ }
2774
+ budget -= cost;
2775
+ page.push(line);
2776
+ }
2777
+ return { ok: true, path, startLine, lines: page, totalLines: lines.length, version };
2778
+ }
2779
+
2555
2780
  // src/sessions/git/baseline.ts
2556
- import { lstat } from "fs/promises";
2557
- import { join as join5 } from "path";
2781
+ import { lstat as lstat2 } from "fs/promises";
2782
+ import { join as join6 } from "path";
2558
2783
 
2559
2784
  // src/sessions/git/base.ts
2560
2785
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
@@ -2595,7 +2820,7 @@ var MAX_PREEXISTING_FILES = 200;
2595
2820
  var DELETED = "deleted";
2596
2821
  async function fingerprintFile(cwd, path) {
2597
2822
  try {
2598
- const stats = await lstat(join5(cwd, path));
2823
+ const stats = await lstat2(join6(cwd, path));
2599
2824
  return `${stats.size}:${Math.trunc(stats.mtimeMs)}`;
2600
2825
  } catch {
2601
2826
  return DELETED;
@@ -2613,7 +2838,7 @@ async function listChangedPaths(cwd, baseSha) {
2613
2838
  runGitDiff(["--relative", "--name-only", "-z", baseSha], cwd),
2614
2839
  runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
2615
2840
  ]);
2616
- const paths = [...tracked.split("\0"), ...untracked.split("\0")].filter((p) => p.length > 0);
2841
+ const paths = [...splitNulList(tracked), ...splitNulList(untracked)];
2617
2842
  return [...new Set(paths)];
2618
2843
  }
2619
2844
  async function captureBaseline(cwd, options) {
@@ -2725,7 +2950,7 @@ async function isGitRepo(cwd) {
2725
2950
  }
2726
2951
  function parseNumstat(output) {
2727
2952
  const map = /* @__PURE__ */ new Map();
2728
- const fields = output.split("\0").filter((f) => f.length > 0);
2953
+ const fields = splitNulList(output);
2729
2954
  let i = 0;
2730
2955
  while (i < fields.length) {
2731
2956
  const line = fields[i];
@@ -2766,7 +2991,7 @@ function isStatusLetter(letter) {
2766
2991
  return letter in STATUS_BY_LETTER;
2767
2992
  }
2768
2993
  function parseNameStatus(output) {
2769
- const fields = output.split("\0").filter((f) => f.length > 0);
2994
+ const fields = splitNulList(output);
2770
2995
  const changed = [];
2771
2996
  let i = 0;
2772
2997
  while (i < fields.length) {
@@ -2785,16 +3010,57 @@ function parseNameStatus(output) {
2785
3010
  }
2786
3011
  return changed;
2787
3012
  }
3013
+ var reportedFailures = /* @__PURE__ */ new Map();
3014
+ async function loadDefaultBranchView(cwd, baseSha) {
3015
+ const defaultRef = await findDefaultBranchRef(cwd);
3016
+ if (!defaultRef) {
3017
+ reportedFailures.delete(cwd);
3018
+ return null;
3019
+ }
3020
+ try {
3021
+ const defaultSha = (await runGit(["rev-parse", "--verify", "--quiet", `${defaultRef}^{commit}`], cwd)).trim();
3022
+ const forkSha = (await runGit(["merge-base", baseSha, defaultSha], cwd)).trim();
3023
+ const [differing, deletedSinceFork] = await Promise.all([
3024
+ runGitDiff(["--relative", "--name-only", "-z", defaultSha], cwd),
3025
+ runGitDiff(["--relative", "--name-only", "--diff-filter=D", "-z", forkSha, defaultSha], cwd)
3026
+ ]);
3027
+ reportedFailures.delete(cwd);
3028
+ return {
3029
+ differing: new Set(splitNulList(differing)),
3030
+ deletedSinceFork: new Set(splitNulList(deletedSinceFork))
3031
+ };
3032
+ } catch (error2) {
3033
+ const signature = `${defaultRef}: ${error2 instanceof Error ? error2.message : String(error2)}`;
3034
+ if (reportedFailures.get(cwd) !== signature) {
3035
+ reportedFailures.set(cwd, signature);
3036
+ console.error("Failed to compare with the default branch; listing files unfiltered", {
3037
+ cwd,
3038
+ defaultRef,
3039
+ error: error2
3040
+ });
3041
+ }
3042
+ return null;
3043
+ }
3044
+ }
3045
+ function dropAlreadyOnDefaultBranch(files, view) {
3046
+ if (!view) return files;
3047
+ return files.filter((file) => {
3048
+ if (file.status === "untracked") return true;
3049
+ if (file.status === "deleted") return !view.deletedSinceFork.has(file.path);
3050
+ return view.differing.has(file.path) || file.previousPath !== void 0 && view.differing.has(file.previousPath);
3051
+ });
3052
+ }
2788
3053
  async function getWorkspaceStatus(cwd, baseline) {
2789
3054
  const branch = await getCurrentBranch(cwd);
2790
3055
  if (branch === null && !await isGitRepo(cwd)) {
2791
3056
  return { isGitRepo: false, branch: null, files: [] };
2792
3057
  }
2793
3058
  const base = await resolveBaseFor(cwd, baseline);
2794
- const [nameStatusRaw, numstatRaw, untrackedRaw] = await Promise.all([
3059
+ const [nameStatusRaw, numstatRaw, untrackedRaw, defaultBranchView] = await Promise.all([
2795
3060
  runGitDiff(["--relative", "--name-status", "-z", "-M", base.sha], cwd),
2796
3061
  runGitDiff(["--relative", "--numstat", "-z", "-M", base.sha], cwd),
2797
- runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
3062
+ runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd),
3063
+ loadDefaultBranchView(cwd, base.sha)
2798
3064
  ]);
2799
3065
  const numstat = parseNumstat(numstatRaw);
2800
3066
  const tracked = parseNameStatus(nameStatusRaw).map((changed) => {
@@ -2809,7 +3075,7 @@ async function getWorkspaceStatus(cwd, baseline) {
2809
3075
  binary: counts?.binary ?? false
2810
3076
  };
2811
3077
  });
2812
- const untracked = untrackedRaw.split("\0").filter((path) => path.length > 0).map((path) => ({
3078
+ const untracked = splitNulList(untrackedRaw).map((path) => ({
2813
3079
  path,
2814
3080
  status: "untracked",
2815
3081
  staged: false,
@@ -2817,7 +3083,7 @@ async function getWorkspaceStatus(cwd, baseline) {
2817
3083
  deletions: null,
2818
3084
  binary: false
2819
3085
  }));
2820
- const files = [...tracked, ...untracked];
3086
+ const files = dropAlreadyOnDefaultBranch([...tracked, ...untracked], defaultBranchView);
2821
3087
  return {
2822
3088
  isGitRepo: true,
2823
3089
  branch,
@@ -2852,8 +3118,12 @@ async function isUntracked(cwd, path) {
2852
3118
  const out = await runGit(["ls-files", "--others", "--exclude-standard", "--", path], cwd);
2853
3119
  return out.trim() !== "";
2854
3120
  }
3121
+ function isOutputOverflow(err) {
3122
+ return err?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
3123
+ }
2855
3124
  async function getWorkspaceDiff(cwd, path, staged, baseline) {
2856
3125
  const base = await resolveBaseFor(cwd, baseline);
3126
+ const version = await currentFileVersion(cwd, path);
2857
3127
  const pathspec = await withRenameSource(cwd, base.sha, path);
2858
3128
  const numstat = await runGitDiff(
2859
3129
  ["--relative", "--numstat", "-M", base.sha, "--", ...pathspec],
@@ -2863,13 +3133,27 @@ async function getWorkspaceDiff(cwd, path, staged, baseline) {
2863
3133
  if (binary) {
2864
3134
  return { path, staged, patch: null, binary: true };
2865
3135
  }
2866
- let patch = await runGitDiff(["--relative", "-M", base.sha, "--", ...pathspec], cwd);
2867
- if (patch.trim() === "" && await isUntracked(cwd, path)) {
2868
- patch = await runGitDiffAllowingExitCodes(["--no-index", "--", "/dev/null", path], cwd, [
2869
- 1
2870
- ]).catch(() => "");
3136
+ try {
3137
+ let patch = await runGitDiff(["--relative", "-M", base.sha, "--", ...pathspec], cwd);
3138
+ if (patch.trim() === "" && await isUntracked(cwd, path)) {
3139
+ patch = await runGitDiffAllowingExitCodes(["--no-index", "--", "/dev/null", path], cwd, [
3140
+ 1
3141
+ ]).catch((err) => {
3142
+ if (isOutputOverflow(err)) throw err;
3143
+ return "";
3144
+ });
3145
+ }
3146
+ return {
3147
+ path,
3148
+ staged,
3149
+ patch: patch || null,
3150
+ binary: false,
3151
+ ...version === void 0 ? {} : { version }
3152
+ };
3153
+ } catch (err) {
3154
+ if (isOutputOverflow(err)) return { path, staged, patch: null, binary: false, tooLarge: true };
3155
+ throw err;
2871
3156
  }
2872
- return { path, staged, patch: patch || null, binary: false };
2873
3157
  }
2874
3158
 
2875
3159
  // src/ws/server.ts
@@ -3092,7 +3376,7 @@ function resolveSessionAttention(report, lastActivityAt, nowMs) {
3092
3376
  // src/sessions/tmux/create.ts
3093
3377
  import { access } from "fs/promises";
3094
3378
  import { homedir as homedir5 } from "os";
3095
- import { join as join6 } from "path";
3379
+ import { join as join7 } from "path";
3096
3380
  var InvalidCwdError = class extends Error {
3097
3381
  constructor(cwd) {
3098
3382
  super(`working directory does not exist: ${cwd}`);
@@ -3100,7 +3384,7 @@ var InvalidCwdError = class extends Error {
3100
3384
  }
3101
3385
  };
3102
3386
  function worktreePathFor(sessionId) {
3103
- return join6(homedir5(), ".bondsmith", "worktrees", sessionId);
3387
+ return join7(homedir5(), ".bondsmith", "worktrees", sessionId);
3104
3388
  }
3105
3389
  async function createTmuxSession(input) {
3106
3390
  const sourceCwd = expandHome(input.cwd);
@@ -3484,6 +3768,7 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
3484
3768
  broadcast,
3485
3769
  getWorkspaceStatus,
3486
3770
  getWorkspaceDiff,
3771
+ readFileRange,
3487
3772
  validateProjectPath,
3488
3773
  hostname: () => hostname(),
3489
3774
  daemonFingerprint,
@@ -3524,10 +3809,10 @@ if (isVersionRequest(process.argv.slice(2))) {
3524
3809
  process.exit(0);
3525
3810
  }
3526
3811
  function loadRepoRootEnv() {
3527
- const repoRoot = join7(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
3528
- if (!existsSync(join7(repoRoot, "pnpm-workspace.yaml"))) return;
3529
- const envLocal = join7(repoRoot, ".env.local");
3530
- const envDefault = join7(repoRoot, ".env");
3812
+ const repoRoot = join8(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
3813
+ if (!existsSync(join8(repoRoot, "pnpm-workspace.yaml"))) return;
3814
+ const envLocal = join8(repoRoot, ".env.local");
3815
+ const envDefault = join8(repoRoot, ".env");
3531
3816
  loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
3532
3817
  }
3533
3818
  loadRepoRootEnv();
@@ -3581,6 +3866,7 @@ async function start() {
3581
3866
  ptyAttachments,
3582
3867
  getWorkspaceStatus,
3583
3868
  getWorkspaceDiff,
3869
+ readFileRange,
3584
3870
  validateProjectPath,
3585
3871
  hostname: hostname2,
3586
3872
  pendingAuthorizations,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bondsmith/bondsmithd",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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",