@bondsmith/bondsmithd 0.1.8 → 0.1.10
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 +113 -12
- 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
|
@@ -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 = [
|
|
@@ -62,6 +62,7 @@ var MAX_INPUT_CHARS = 16 * 1024;
|
|
|
62
62
|
var MAX_WIRE_MESSAGE_BYTES = 256 * 1024;
|
|
63
63
|
var MAX_DAEMON_REPLY_BYTES = 200 * 1024;
|
|
64
64
|
var MAX_RANGE_LINES = 500;
|
|
65
|
+
var MAX_PLAN_CHARS = 32 * 1024;
|
|
65
66
|
var MAX_TERMINAL_DIMENSION = 1e3;
|
|
66
67
|
var terminalSizeSchema = z2.object({
|
|
67
68
|
cols: z2.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
|
|
@@ -202,6 +203,11 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
|
|
|
202
203
|
staged: z2.boolean(),
|
|
203
204
|
requestId: z2.string().min(1).max(200)
|
|
204
205
|
}),
|
|
206
|
+
z2.object({
|
|
207
|
+
type: z2.literal("workspace.getPlan"),
|
|
208
|
+
sessionId: z2.string(),
|
|
209
|
+
requestId: z2.string().min(1).max(200)
|
|
210
|
+
}),
|
|
205
211
|
z2.object({
|
|
206
212
|
type: z2.literal("project.validatePath"),
|
|
207
213
|
path: z2.string().min(1).max(4096),
|
|
@@ -291,6 +297,12 @@ var daemonMessageSchema = z2.discriminatedUnion("type", [
|
|
|
291
297
|
requestId: z2.string(),
|
|
292
298
|
diff: workspaceFileDiffSchema
|
|
293
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
|
+
}),
|
|
294
306
|
z2.object({
|
|
295
307
|
type: z2.literal("project.pathValidated"),
|
|
296
308
|
requestId: z2.string(),
|
|
@@ -406,6 +418,7 @@ var SIGNABLE_ACTIONS = [
|
|
|
406
418
|
"workspace.getStatus",
|
|
407
419
|
"workspace.getDiff",
|
|
408
420
|
"workspace.readFileRange",
|
|
421
|
+
"workspace.getPlan",
|
|
409
422
|
"project.validatePath"
|
|
410
423
|
];
|
|
411
424
|
var envelopeSchema = z3.object({
|
|
@@ -891,6 +904,9 @@ import { promisify } from "util";
|
|
|
891
904
|
var execFile = promisify(execFileCb);
|
|
892
905
|
var MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
893
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
|
+
}
|
|
894
910
|
var GitNotARepoError = class extends Error {
|
|
895
911
|
constructor() {
|
|
896
912
|
super("not a git repository");
|
|
@@ -990,7 +1006,7 @@ import { sign } from "crypto";
|
|
|
990
1006
|
import WebSocket from "ws";
|
|
991
1007
|
|
|
992
1008
|
// src/version.ts
|
|
993
|
-
var DAEMON_VERSION = true ? "0.1.
|
|
1009
|
+
var DAEMON_VERSION = true ? "0.1.10" : "dev";
|
|
994
1010
|
var VERSION_FLAGS = ["--version", "-v"];
|
|
995
1011
|
function versionLine() {
|
|
996
1012
|
return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
|
|
@@ -1061,6 +1077,16 @@ async function getSessionActivity(tmuxSessionName) {
|
|
|
1061
1077
|
return null;
|
|
1062
1078
|
}
|
|
1063
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
|
+
}
|
|
1064
1090
|
async function listTmuxSessions(formatString) {
|
|
1065
1091
|
try {
|
|
1066
1092
|
return await runTmux(["list-sessions", "-F", formatString]);
|
|
@@ -1331,6 +1357,14 @@ function createOutputPacer(options) {
|
|
|
1331
1357
|
};
|
|
1332
1358
|
}
|
|
1333
1359
|
|
|
1360
|
+
// src/ws/sanitize-insert-text.ts
|
|
1361
|
+
var DISALLOWED_CHARACTERS = /[\x00-\x08\x0b-\x1f\x7f-\x9f--]/g;
|
|
1362
|
+
function sanitizeInsertText(text) {
|
|
1363
|
+
const withLineFeeds = text.replace(/\r\n?/g, "\n");
|
|
1364
|
+
const sanitized = withLineFeeds.replace(DISALLOWED_CHARACTERS, "");
|
|
1365
|
+
return { text: sanitized, strippedCount: withLineFeeds.length - sanitized.length };
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1334
1368
|
// src/ws/router.ts
|
|
1335
1369
|
var SIGNABLE_ACTION_SET = new Set(SIGNABLE_ACTIONS);
|
|
1336
1370
|
function isSignableAction(type) {
|
|
@@ -1614,10 +1648,14 @@ async function dispatch(conn, message, {
|
|
|
1614
1648
|
case "session.insertText": {
|
|
1615
1649
|
const session = getSessionOrError(sessionManager, conn, message.sessionId);
|
|
1616
1650
|
if (!session) return;
|
|
1651
|
+
const { text, strippedCount } = sanitizeInsertText(message.text);
|
|
1652
|
+
if (strippedCount > 0) {
|
|
1653
|
+
console.warn(`bondsmithd: stripped ${strippedCount} control characters from inserted text`);
|
|
1654
|
+
}
|
|
1617
1655
|
try {
|
|
1618
1656
|
await sendKeysLiteral(
|
|
1619
1657
|
session.tmuxSessionName,
|
|
1620
|
-
`${BRACKETED_PASTE_START}${
|
|
1658
|
+
`${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`
|
|
1621
1659
|
);
|
|
1622
1660
|
} catch (err) {
|
|
1623
1661
|
conn.send(error(errorMessageOf(err, "failed to insert text")));
|
|
@@ -1709,6 +1747,22 @@ async function dispatch(conn, message, {
|
|
|
1709
1747
|
}
|
|
1710
1748
|
return;
|
|
1711
1749
|
}
|
|
1750
|
+
case "workspace.getPlan": {
|
|
1751
|
+
const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
|
|
1752
|
+
if (!session) return;
|
|
1753
|
+
try {
|
|
1754
|
+
const plan = await getSessionPlan(session.tmuxSessionName);
|
|
1755
|
+
conn.send({
|
|
1756
|
+
type: "workspace.plan",
|
|
1757
|
+
sessionId: message.sessionId,
|
|
1758
|
+
requestId: message.requestId,
|
|
1759
|
+
plan
|
|
1760
|
+
});
|
|
1761
|
+
} catch (err) {
|
|
1762
|
+
conn.send(error(errorMessageOf(err, "failed to read the session plan"), message.requestId));
|
|
1763
|
+
}
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1712
1766
|
case "project.validatePath": {
|
|
1713
1767
|
try {
|
|
1714
1768
|
const result = await validateProjectPath2(message.path);
|
|
@@ -1821,6 +1875,10 @@ var ConnectionAuthState = class {
|
|
|
1821
1875
|
isRevoked() {
|
|
1822
1876
|
return this.revoked;
|
|
1823
1877
|
}
|
|
1878
|
+
/** Unsolicited daemon broadcasts (session.created/updated) go only to connections that completed the handshake and still hold authority. */
|
|
1879
|
+
canReceiveBroadcasts() {
|
|
1880
|
+
return this.authenticated !== null && !this.revoked;
|
|
1881
|
+
}
|
|
1824
1882
|
/** The controller/account this connection authenticated as, or null before a successful handshake. Used by verify-command.ts to reject an envelope signed by a DIFFERENT controller than the one that authenticated this connection. */
|
|
1825
1883
|
getAuthenticatedIdentity() {
|
|
1826
1884
|
return this.authenticated;
|
|
@@ -1998,7 +2056,7 @@ function startRelayClient(deps) {
|
|
|
1998
2056
|
let reconnectTimer;
|
|
1999
2057
|
function broadcast(message) {
|
|
2000
2058
|
for (const entry of sessions.values()) {
|
|
2001
|
-
entry.conn.send(message);
|
|
2059
|
+
if (entry.connectionAuth.canReceiveBroadcasts()) entry.conn.send(message);
|
|
2002
2060
|
}
|
|
2003
2061
|
}
|
|
2004
2062
|
const routerDeps = { ...deps, broadcast };
|
|
@@ -2796,7 +2854,7 @@ async function listChangedPaths(cwd, baseSha) {
|
|
|
2796
2854
|
runGitDiff(["--relative", "--name-only", "-z", baseSha], cwd),
|
|
2797
2855
|
runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
|
|
2798
2856
|
]);
|
|
2799
|
-
const paths = [...tracked
|
|
2857
|
+
const paths = [...splitNulList(tracked), ...splitNulList(untracked)];
|
|
2800
2858
|
return [...new Set(paths)];
|
|
2801
2859
|
}
|
|
2802
2860
|
async function captureBaseline(cwd, options) {
|
|
@@ -2908,7 +2966,7 @@ async function isGitRepo(cwd) {
|
|
|
2908
2966
|
}
|
|
2909
2967
|
function parseNumstat(output) {
|
|
2910
2968
|
const map = /* @__PURE__ */ new Map();
|
|
2911
|
-
const fields = output
|
|
2969
|
+
const fields = splitNulList(output);
|
|
2912
2970
|
let i = 0;
|
|
2913
2971
|
while (i < fields.length) {
|
|
2914
2972
|
const line = fields[i];
|
|
@@ -2949,7 +3007,7 @@ function isStatusLetter(letter) {
|
|
|
2949
3007
|
return letter in STATUS_BY_LETTER;
|
|
2950
3008
|
}
|
|
2951
3009
|
function parseNameStatus(output) {
|
|
2952
|
-
const fields = output
|
|
3010
|
+
const fields = splitNulList(output);
|
|
2953
3011
|
const changed = [];
|
|
2954
3012
|
let i = 0;
|
|
2955
3013
|
while (i < fields.length) {
|
|
@@ -2968,16 +3026,57 @@ function parseNameStatus(output) {
|
|
|
2968
3026
|
}
|
|
2969
3027
|
return changed;
|
|
2970
3028
|
}
|
|
3029
|
+
var reportedFailures = /* @__PURE__ */ new Map();
|
|
3030
|
+
async function loadDefaultBranchView(cwd, baseSha) {
|
|
3031
|
+
const defaultRef = await findDefaultBranchRef(cwd);
|
|
3032
|
+
if (!defaultRef) {
|
|
3033
|
+
reportedFailures.delete(cwd);
|
|
3034
|
+
return null;
|
|
3035
|
+
}
|
|
3036
|
+
try {
|
|
3037
|
+
const defaultSha = (await runGit(["rev-parse", "--verify", "--quiet", `${defaultRef}^{commit}`], cwd)).trim();
|
|
3038
|
+
const forkSha = (await runGit(["merge-base", baseSha, defaultSha], cwd)).trim();
|
|
3039
|
+
const [differing, deletedSinceFork] = await Promise.all([
|
|
3040
|
+
runGitDiff(["--relative", "--name-only", "-z", defaultSha], cwd),
|
|
3041
|
+
runGitDiff(["--relative", "--name-only", "--diff-filter=D", "-z", forkSha, defaultSha], cwd)
|
|
3042
|
+
]);
|
|
3043
|
+
reportedFailures.delete(cwd);
|
|
3044
|
+
return {
|
|
3045
|
+
differing: new Set(splitNulList(differing)),
|
|
3046
|
+
deletedSinceFork: new Set(splitNulList(deletedSinceFork))
|
|
3047
|
+
};
|
|
3048
|
+
} catch (error2) {
|
|
3049
|
+
const signature = `${defaultRef}: ${error2 instanceof Error ? error2.message : String(error2)}`;
|
|
3050
|
+
if (reportedFailures.get(cwd) !== signature) {
|
|
3051
|
+
reportedFailures.set(cwd, signature);
|
|
3052
|
+
console.error("Failed to compare with the default branch; listing files unfiltered", {
|
|
3053
|
+
cwd,
|
|
3054
|
+
defaultRef,
|
|
3055
|
+
error: error2
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
return null;
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
function dropAlreadyOnDefaultBranch(files, view) {
|
|
3062
|
+
if (!view) return files;
|
|
3063
|
+
return files.filter((file) => {
|
|
3064
|
+
if (file.status === "untracked") return true;
|
|
3065
|
+
if (file.status === "deleted") return !view.deletedSinceFork.has(file.path);
|
|
3066
|
+
return view.differing.has(file.path) || file.previousPath !== void 0 && view.differing.has(file.previousPath);
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
2971
3069
|
async function getWorkspaceStatus(cwd, baseline) {
|
|
2972
3070
|
const branch = await getCurrentBranch(cwd);
|
|
2973
3071
|
if (branch === null && !await isGitRepo(cwd)) {
|
|
2974
3072
|
return { isGitRepo: false, branch: null, files: [] };
|
|
2975
3073
|
}
|
|
2976
3074
|
const base = await resolveBaseFor(cwd, baseline);
|
|
2977
|
-
const [nameStatusRaw, numstatRaw, untrackedRaw] = await Promise.all([
|
|
3075
|
+
const [nameStatusRaw, numstatRaw, untrackedRaw, defaultBranchView] = await Promise.all([
|
|
2978
3076
|
runGitDiff(["--relative", "--name-status", "-z", "-M", base.sha], cwd),
|
|
2979
3077
|
runGitDiff(["--relative", "--numstat", "-z", "-M", base.sha], cwd),
|
|
2980
|
-
runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
|
|
3078
|
+
runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd),
|
|
3079
|
+
loadDefaultBranchView(cwd, base.sha)
|
|
2981
3080
|
]);
|
|
2982
3081
|
const numstat = parseNumstat(numstatRaw);
|
|
2983
3082
|
const tracked = parseNameStatus(nameStatusRaw).map((changed) => {
|
|
@@ -2992,7 +3091,7 @@ async function getWorkspaceStatus(cwd, baseline) {
|
|
|
2992
3091
|
binary: counts?.binary ?? false
|
|
2993
3092
|
};
|
|
2994
3093
|
});
|
|
2995
|
-
const untracked = untrackedRaw
|
|
3094
|
+
const untracked = splitNulList(untrackedRaw).map((path) => ({
|
|
2996
3095
|
path,
|
|
2997
3096
|
status: "untracked",
|
|
2998
3097
|
staged: false,
|
|
@@ -3000,7 +3099,7 @@ async function getWorkspaceStatus(cwd, baseline) {
|
|
|
3000
3099
|
deletions: null,
|
|
3001
3100
|
binary: false
|
|
3002
3101
|
}));
|
|
3003
|
-
const files = [...tracked, ...untracked];
|
|
3102
|
+
const files = dropAlreadyOnDefaultBranch([...tracked, ...untracked], defaultBranchView);
|
|
3004
3103
|
return {
|
|
3005
3104
|
isGitRepo: true,
|
|
3006
3105
|
branch,
|
|
@@ -3631,7 +3730,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
3631
3730
|
function broadcast(message) {
|
|
3632
3731
|
const payload = JSON.stringify(message);
|
|
3633
3732
|
for (const client of wss.clients) {
|
|
3634
|
-
if (client.readyState === client.OPEN
|
|
3733
|
+
if (client.readyState === client.OPEN && connectionAuthBySocket.get(client)?.canReceiveBroadcasts()) {
|
|
3734
|
+
client.send(payload);
|
|
3735
|
+
}
|
|
3635
3736
|
}
|
|
3636
3737
|
}
|
|
3637
3738
|
sessionManager.onSessionUpdated((session) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bondsmith/bondsmithd",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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",
|