@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.
- package/README.md +24 -0
- package/dist/index.js +288 -44
- 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
|
|
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 =
|
|
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.
|
|
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
|
|
1054
|
+
async function getSessionActivity(tmuxSessionName) {
|
|
1016
1055
|
try {
|
|
1017
|
-
const out = (await runTmux(["display-message", "-p", "-t", tmuxSessionName, "#{
|
|
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
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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.
|
|
1702
|
+
type: "workspace.fileRange",
|
|
1607
1703
|
sessionId: message.sessionId,
|
|
1608
1704
|
requestId: message.requestId,
|
|
1609
|
-
|
|
1705
|
+
range
|
|
1610
1706
|
});
|
|
1611
1707
|
} catch (err) {
|
|
1612
|
-
conn.send(error(errorMessageOf(err, "failed to read
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2864
|
-
|
|
2865
|
-
patch
|
|
2866
|
-
|
|
2867
|
-
|
|
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
|
|
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
|
|
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
|
|
3519
|
+
let agentReports = /* @__PURE__ */ new Map();
|
|
3284
3520
|
try {
|
|
3285
|
-
|
|
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 (!
|
|
3535
|
+
if (!agentReports.has(session.tmuxSessionName)) {
|
|
3301
3536
|
this.removeAsExited(session);
|
|
3302
3537
|
return;
|
|
3303
3538
|
}
|
|
3304
3539
|
try {
|
|
3305
|
-
const
|
|
3540
|
+
const enriched = await enrichSessionWithGitMetadata(
|
|
3306
3541
|
session,
|
|
3307
3542
|
this.baselines.get(session.id)
|
|
3308
3543
|
);
|
|
3309
|
-
|
|
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
|
|
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 =
|
|
3487
|
-
if (!existsSync(
|
|
3488
|
-
const envLocal =
|
|
3489
|
-
const envDefault =
|
|
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.
|
|
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",
|