@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.
- package/README.md +15 -0
- package/dist/index.js +324 -38
- 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
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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.
|
|
1728
|
+
type: "workspace.fileRange",
|
|
1610
1729
|
sessionId: message.sessionId,
|
|
1611
1730
|
requestId: message.requestId,
|
|
1612
|
-
|
|
1731
|
+
range
|
|
1613
1732
|
});
|
|
1614
1733
|
} catch (err) {
|
|
1615
|
-
conn.send(error(errorMessageOf(err, "failed to read
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2867
|
-
|
|
2868
|
-
patch
|
|
2869
|
-
|
|
2870
|
-
|
|
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
|
|
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
|
|
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 =
|
|
3528
|
-
if (!existsSync(
|
|
3529
|
-
const envLocal =
|
|
3530
|
-
const envDefault =
|
|
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.
|
|
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",
|