@bondsmith/bondsmithd 0.1.13 → 0.1.15
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 +11 -6
- package/dist/index.js +246 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,20 +82,20 @@ that pane belongs to:
|
|
|
82
82
|
tmux set-option -t "$TMUX_PANE" -q @bondsmith-agent-state "waiting:$(date +%s)"
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
The value is `<state>:<epoch seconds>`, where state is `working`, `waiting` (needs the user) or `done
|
|
85
|
+
The value is `<state>:<epoch seconds>`, where state is `working`, `waiting` (needs the user) or `done` (finished a turn, shown until the user attaches, types or resizes after it).
|
|
86
86
|
The daemon reads it on its 5-second tick and shows it on the session. Anything else is ignored, as is
|
|
87
87
|
a report dated in the future. A `waiting` or `done` report is dropped once the pane writes output more
|
|
88
88
|
than 2 seconds after it, since that means the agent moved on. A `working` report is dropped once the
|
|
89
89
|
pane has been silent for 2 minutes, since an interrupted or crashed agent never reports again.
|
|
90
90
|
|
|
91
91
|
Claude Code sessions that Bondsmith starts as plain `claude` report this on their own (see the end of the
|
|
92
|
-
plan section below): `working` when a prompt is sent, `waiting` when it asks for permission
|
|
93
|
-
|
|
92
|
+
plan section below): `working` when a prompt is sent, `waiting` when it asks for permission or fails with
|
|
93
|
+
an API error, `done` when it finishes a turn. Nothing needs installing for those.
|
|
94
94
|
|
|
95
95
|
For an agent the daemon does not set up (a wrapper, `npx claude`, another agent), note that every session
|
|
96
96
|
Bondsmith starts has `BONDSMITH_SESSION` set to its id, so a hook installed once for all your projects can
|
|
97
97
|
stay silent everywhere else. This entry in `~/.claude/settings.json` reports a Claude Code session as
|
|
98
|
-
waiting the moment it asks for permission
|
|
98
|
+
waiting the moment it asks for permission, and as done when it finishes a turn:
|
|
99
99
|
|
|
100
100
|
```json
|
|
101
101
|
{
|
|
@@ -116,7 +116,7 @@ waiting the moment it asks for permission or finishes a turn:
|
|
|
116
116
|
"hooks": [
|
|
117
117
|
{
|
|
118
118
|
"type": "command",
|
|
119
|
-
"command": "[ -n \"$BONDSMITH_SESSION\" ] && tmux set-option -t \"$TMUX_PANE\" -q @bondsmith-agent-state \"
|
|
119
|
+
"command": "[ -n \"$BONDSMITH_SESSION\" ] && tmux set-option -t \"$TMUX_PANE\" -q @bondsmith-agent-state \"done:$(date +%s)\"; exit 0"
|
|
120
120
|
}
|
|
121
121
|
]
|
|
122
122
|
}
|
|
@@ -127,7 +127,7 @@ waiting the moment it asks for permission or finishes a turn:
|
|
|
127
127
|
|
|
128
128
|
This is optional, and nothing edits your settings for you. The command does nothing outside a Bondsmith
|
|
129
129
|
session, and sessions started before the daemon set the variable are skipped by it. It reports only
|
|
130
|
-
`waiting`. A `working` report is dropped after the pane has been silent for 2 minutes, so one that an
|
|
130
|
+
`waiting` and `done`. A `working` report is dropped after the pane has been silent for 2 minutes, so one that an
|
|
131
131
|
interrupted Claude Code never clears (it does not run `Stop` then, and has no hook for Escape) expires on
|
|
132
132
|
its own.
|
|
133
133
|
|
|
@@ -157,6 +157,11 @@ that only answers the agent (`yes`, `continue`, `ok`, `thanks`, and a few like t
|
|
|
157
157
|
characters leaves the earlier headline in place; a built-in slash command such as `/help` sends no prompt to
|
|
158
158
|
the hook, so it does too. The prompt is stored as typed, so a secret in a prompt is in the headline.
|
|
159
159
|
|
|
160
|
+
A sixth hook watches Bash calls for `gh pr create`. When one succeeds and prints a pull request URL, the URL is
|
|
161
|
+
appended to the tmux option `@bondsmith-prs` (space separated, each once, the newest 20 kept), and the daemon
|
|
162
|
+
reports it on the session as `pullRequestUrls`. It matches `gh pr create` only as a command of its own, so
|
|
163
|
+
`echo "gh pr create"` or `gh pr view` record nothing, and a failed command never reaches it.
|
|
164
|
+
|
|
160
165
|
The daemon leaves the command alone if it already has `--settings`, if the installed `claude` does not list
|
|
161
166
|
`--settings` in `claude --help` (asked once per daemon run; if `claude` cannot be run at all, that is logged
|
|
162
167
|
and asked again after a minute), or if `BONDSMITH_AGENT_HOOKS` is set to `off`, `0`, `false` or `no` where
|
package/dist/index.js
CHANGED
|
@@ -10,13 +10,15 @@ import { fileURLToPath } from "url";
|
|
|
10
10
|
// ../../packages/local-agent-protocol/src/session.ts
|
|
11
11
|
var SESSION_STATUSES = ["running", "exited"];
|
|
12
12
|
var AGENT_STATES = ["working", "waiting", "done"];
|
|
13
|
+
var MAX_SKIPPED_FILES_REPORTED = 20;
|
|
14
|
+
var MAX_SESSION_PULL_REQUESTS = 20;
|
|
13
15
|
|
|
14
16
|
// ../../packages/local-agent-protocol/src/messages.ts
|
|
15
17
|
var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
|
|
16
18
|
var INVALID_MESSAGE_ERROR = "invalid message";
|
|
17
19
|
|
|
18
20
|
// ../../packages/local-agent-protocol/src/protocol-version.ts
|
|
19
|
-
var PROTOCOL_VERSION =
|
|
21
|
+
var PROTOCOL_VERSION = 19;
|
|
20
22
|
|
|
21
23
|
// ../../packages/local-agent-protocol/src/branch-name.ts
|
|
22
24
|
var MAX_BRANCH_NAME_LENGTH = 250;
|
|
@@ -204,7 +206,9 @@ var localSessionSchema = z3.object({
|
|
|
204
206
|
// .catch(undefined): a state this version doesn't know must never drop the whole session message.
|
|
205
207
|
agentState: z3.enum(AGENT_STATES).nullable().optional().catch(void 0),
|
|
206
208
|
worktreeSetupFailed: z3.boolean().optional().catch(void 0),
|
|
207
|
-
|
|
209
|
+
worktreeSetupSkipped: z3.array(z3.string().max(300)).max(MAX_SKIPPED_FILES_REPORTED).optional().catch(void 0),
|
|
210
|
+
headline: z3.string().nullable().optional().catch(void 0),
|
|
211
|
+
pullRequestUrls: z3.array(z3.string().max(300)).max(MAX_SESSION_PULL_REQUESTS).optional().catch(void 0)
|
|
208
212
|
});
|
|
209
213
|
var workspaceFileEntrySchema = z3.object({
|
|
210
214
|
path: z3.string(),
|
|
@@ -315,6 +319,11 @@ var browserMessageSchema = z3.discriminatedUnion("type", [
|
|
|
315
319
|
sessionId: z3.string(),
|
|
316
320
|
...terminalSizeSchema.shape
|
|
317
321
|
}),
|
|
322
|
+
z3.object({
|
|
323
|
+
type: z3.literal("session.setMouseMode"),
|
|
324
|
+
sessionId: z3.string(),
|
|
325
|
+
enabled: z3.boolean()
|
|
326
|
+
}),
|
|
318
327
|
z3.object({ type: z3.literal("session.stop"), sessionId: z3.string() }),
|
|
319
328
|
z3.object({
|
|
320
329
|
type: z3.literal("workspace.getStatus"),
|
|
@@ -525,6 +534,7 @@ var SIGNABLE_ACTIONS = [
|
|
|
525
534
|
"session.input",
|
|
526
535
|
"session.insertText",
|
|
527
536
|
"session.resize",
|
|
537
|
+
"session.setMouseMode",
|
|
528
538
|
"session.stop",
|
|
529
539
|
"session.list",
|
|
530
540
|
"workspace.getStatus",
|
|
@@ -1184,7 +1194,7 @@ function expandHome(cwd) {
|
|
|
1184
1194
|
}
|
|
1185
1195
|
|
|
1186
1196
|
// src/sessions/git/client.ts
|
|
1187
|
-
import { execFile as execFileCb } from "child_process";
|
|
1197
|
+
import { execFile as execFileCb, spawn as spawn2 } from "child_process";
|
|
1188
1198
|
import { promisify } from "util";
|
|
1189
1199
|
var execFile = promisify(execFileCb);
|
|
1190
1200
|
var MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
@@ -1239,6 +1249,43 @@ function runGitDiff(args, cwd) {
|
|
|
1239
1249
|
function runGitDiffAllowingExitCodes(args, cwd, allowedExitCodes) {
|
|
1240
1250
|
return runGitAllowingExitCodes(["diff", ...SAFE_DIFF_FLAGS, ...args], cwd, allowedExitCodes);
|
|
1241
1251
|
}
|
|
1252
|
+
var GIT_INPUT_TIMEOUT_MS = 3e4;
|
|
1253
|
+
async function runGitWithInput(args, cwd, input, options = {}) {
|
|
1254
|
+
const { allowedExitCodes = [], timeoutMs = GIT_INPUT_TIMEOUT_MS } = options;
|
|
1255
|
+
return new Promise((resolve2, reject) => {
|
|
1256
|
+
const child = spawn2("git", [...SAFE_GIT_CONFIG, ...args], { cwd });
|
|
1257
|
+
const stdout = [];
|
|
1258
|
+
const stderr = [];
|
|
1259
|
+
let settled = false;
|
|
1260
|
+
const settle = (finish) => {
|
|
1261
|
+
if (settled) return;
|
|
1262
|
+
settled = true;
|
|
1263
|
+
clearTimeout(timer);
|
|
1264
|
+
finish();
|
|
1265
|
+
};
|
|
1266
|
+
const timer = setTimeout(() => {
|
|
1267
|
+
child.kill("SIGKILL");
|
|
1268
|
+
settle(() => reject(new Error(`git ${args[0]} timed out after ${timeoutMs} ms`)));
|
|
1269
|
+
}, timeoutMs);
|
|
1270
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
1271
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
1272
|
+
child.stdin.on("error", () => {
|
|
1273
|
+
});
|
|
1274
|
+
child.on("error", (error2) => settle(() => reject(error2)));
|
|
1275
|
+
child.on(
|
|
1276
|
+
"close",
|
|
1277
|
+
(code) => settle(() => {
|
|
1278
|
+
if (code === 0 || code !== null && allowedExitCodes.includes(code)) {
|
|
1279
|
+
resolve2(Buffer.concat(stdout).toString("utf8"));
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const message = Buffer.concat(stderr).toString("utf8").trim();
|
|
1283
|
+
reject(new Error(`git ${args[0]} exited with ${code}${message ? `: ${message}` : ""}`));
|
|
1284
|
+
})
|
|
1285
|
+
);
|
|
1286
|
+
child.stdin.end(input);
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1242
1289
|
|
|
1243
1290
|
// src/sessions/files/plan-file.ts
|
|
1244
1291
|
import { constants as constants2 } from "fs";
|
|
@@ -1418,6 +1465,12 @@ async function runTmux(args, options) {
|
|
|
1418
1465
|
async function sendKeysLiteral(tmuxSessionName, text) {
|
|
1419
1466
|
await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text]);
|
|
1420
1467
|
}
|
|
1468
|
+
async function setMouseMode(tmuxSessionName, enabled) {
|
|
1469
|
+
await runTmux(["set-option", "-t", tmuxSessionName, "mouse", enabled ? "on" : "off"]);
|
|
1470
|
+
}
|
|
1471
|
+
async function cancelCopyMode(tmuxSessionName) {
|
|
1472
|
+
await runTmux(["copy-mode", "-q", "-t", tmuxSessionName]);
|
|
1473
|
+
}
|
|
1421
1474
|
async function capturePane(tmuxSessionName) {
|
|
1422
1475
|
return runTmux(["capture-pane", "-t", tmuxSessionName, "-p", "-e"]);
|
|
1423
1476
|
}
|
|
@@ -2229,7 +2282,7 @@ function listInstalledAgents(env = process.env) {
|
|
|
2229
2282
|
}
|
|
2230
2283
|
|
|
2231
2284
|
// src/version.ts
|
|
2232
|
-
var DAEMON_VERSION = true ? "0.1.
|
|
2285
|
+
var DAEMON_VERSION = true ? "0.1.15" : "dev";
|
|
2233
2286
|
var VERSION_FLAGS = ["--version", "-v"];
|
|
2234
2287
|
function versionLine() {
|
|
2235
2288
|
return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
|
|
@@ -2406,6 +2459,7 @@ function verifyRejectionMessage(reason) {
|
|
|
2406
2459
|
var REVOKED_REJECTION_MESSAGE = "connection has not completed its handshake";
|
|
2407
2460
|
var BRACKETED_PASTE_START = "\x1B[200~";
|
|
2408
2461
|
var BRACKETED_PASTE_END = "\x1B[201~";
|
|
2462
|
+
var WHEEL_ONLY_INPUT = /^(?:\x1b\[<6[45];\d+;\d+M)+$/;
|
|
2409
2463
|
function releaseSession(conn, ptyAttachments, sessionId) {
|
|
2410
2464
|
const listener = conn.getOutputListener(sessionId);
|
|
2411
2465
|
if (listener) ptyAttachments.release(sessionId, listener);
|
|
@@ -2419,6 +2473,7 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
|
|
|
2419
2473
|
let admissionChain = Promise.resolve();
|
|
2420
2474
|
const conn = {
|
|
2421
2475
|
subscriptions: /* @__PURE__ */ new Set(),
|
|
2476
|
+
mouseModeSessions: /* @__PURE__ */ new Map(),
|
|
2422
2477
|
send,
|
|
2423
2478
|
bufferedBytes,
|
|
2424
2479
|
isClosed: () => closed,
|
|
@@ -2457,6 +2512,11 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
|
|
|
2457
2512
|
for (const sessionId of [...conn.subscriptions]) {
|
|
2458
2513
|
releaseSession(conn, ptyAttachments, sessionId);
|
|
2459
2514
|
}
|
|
2515
|
+
for (const tmuxSessionName of conn.mouseModeSessions.values()) {
|
|
2516
|
+
setMouseMode(tmuxSessionName, false).catch(() => {
|
|
2517
|
+
});
|
|
2518
|
+
}
|
|
2519
|
+
conn.mouseModeSessions.clear();
|
|
2460
2520
|
}
|
|
2461
2521
|
return { conn, releaseAllSubscriptions };
|
|
2462
2522
|
}
|
|
@@ -2690,6 +2750,11 @@ async function dispatch(conn, message, {
|
|
|
2690
2750
|
return;
|
|
2691
2751
|
}
|
|
2692
2752
|
sessionManager.acknowledge(message.sessionId);
|
|
2753
|
+
const tmuxSessionName = conn.mouseModeSessions.get(message.sessionId);
|
|
2754
|
+
if (tmuxSessionName && !WHEEL_ONLY_INPUT.test(message.data)) {
|
|
2755
|
+
await cancelCopyMode(tmuxSessionName).catch(() => {
|
|
2756
|
+
});
|
|
2757
|
+
}
|
|
2693
2758
|
ptyAttachments.write(message.sessionId, message.data);
|
|
2694
2759
|
return;
|
|
2695
2760
|
}
|
|
@@ -2720,6 +2785,20 @@ async function dispatch(conn, message, {
|
|
|
2720
2785
|
ptyAttachments.resize(message.sessionId, message.cols, message.rows);
|
|
2721
2786
|
return;
|
|
2722
2787
|
}
|
|
2788
|
+
case "session.setMouseMode": {
|
|
2789
|
+
const session = sessionManager.get(message.sessionId);
|
|
2790
|
+
if (!session) return;
|
|
2791
|
+
if (message.enabled) conn.mouseModeSessions.set(session.id, session.tmuxSessionName);
|
|
2792
|
+
else conn.mouseModeSessions.delete(session.id);
|
|
2793
|
+
try {
|
|
2794
|
+
await setMouseMode(session.tmuxSessionName, message.enabled);
|
|
2795
|
+
if (message.enabled && conn.isClosed()) await setMouseMode(session.tmuxSessionName, false);
|
|
2796
|
+
} catch (err) {
|
|
2797
|
+
if (message.enabled) conn.mouseModeSessions.delete(session.id);
|
|
2798
|
+
console.warn(`bondsmithd: ${errorMessageOf(err, "failed to set mouse mode")}`);
|
|
2799
|
+
}
|
|
2800
|
+
return;
|
|
2801
|
+
}
|
|
2723
2802
|
case "session.stop": {
|
|
2724
2803
|
try {
|
|
2725
2804
|
if (!await sessionManager.stop(message.sessionId)) {
|
|
@@ -3934,11 +4013,27 @@ function wrapCommandWithSetup(input) {
|
|
|
3934
4013
|
].join(" ");
|
|
3935
4014
|
}
|
|
3936
4015
|
|
|
4016
|
+
// src/sessions/worktree-setup/skipped-files.ts
|
|
4017
|
+
var SETUP_SKIPPED_OPTION = "@bondsmith-setup-skipped";
|
|
4018
|
+
function encodeSkippedFiles(names) {
|
|
4019
|
+
return names.slice(0, MAX_SKIPPED_FILES_REPORTED).map(encodeURIComponent).join(",");
|
|
4020
|
+
}
|
|
4021
|
+
function decodeSkippedFiles(raw) {
|
|
4022
|
+
if (raw === "") return [];
|
|
4023
|
+
try {
|
|
4024
|
+
return raw.split(",").map(decodeURIComponent);
|
|
4025
|
+
} catch {
|
|
4026
|
+
return [];
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
|
|
3937
4030
|
// src/sessions/tmux/agent-state.ts
|
|
3938
4031
|
var AGENT_STATE_OPTION = "@bondsmith-agent-state";
|
|
3939
4032
|
var HEADLINE_OPTION = "@bondsmith-headline";
|
|
4033
|
+
var PULL_REQUESTS_OPTION = "@bondsmith-prs";
|
|
4034
|
+
var PULL_REQUEST_URL = /https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+/g;
|
|
3940
4035
|
var MAX_HEADLINE_CHARS = 200;
|
|
3941
|
-
var LIVE_SESSION_REPORTS_FORMAT = `#{session_name} #{${AGENT_STATE_OPTION}} #{window_bell_flag} #{${SETUP_FAILED_OPTION}} #{${HEADLINE_OPTION}}`;
|
|
4036
|
+
var LIVE_SESSION_REPORTS_FORMAT = `#{session_name} #{${AGENT_STATE_OPTION}} #{window_bell_flag} #{${SETUP_FAILED_OPTION}} #{${SETUP_SKIPPED_OPTION}} #{${PULL_REQUESTS_OPTION}} #{${HEADLINE_OPTION}}`;
|
|
3942
4037
|
var AGENT_STATE_OUTPUT_GRACE_MS = 2e3;
|
|
3943
4038
|
var FUTURE_REPORT_ALLOWANCE_MS = 2e3;
|
|
3944
4039
|
var WORKING_SILENCE_MS = 12e4;
|
|
@@ -3950,29 +4045,48 @@ function cleanHeadline(raw) {
|
|
|
3950
4045
|
if (!line) return null;
|
|
3951
4046
|
return line.length > MAX_HEADLINE_CHARS ? `${line.slice(0, MAX_HEADLINE_CHARS - 1)}\u2026` : line;
|
|
3952
4047
|
}
|
|
4048
|
+
function parsePullRequestUrls(raw) {
|
|
4049
|
+
const urls = [...new Set(raw.match(PULL_REQUEST_URL) ?? [])];
|
|
4050
|
+
return urls.slice(-MAX_SESSION_PULL_REQUESTS);
|
|
4051
|
+
}
|
|
3953
4052
|
function isAgentState(value) {
|
|
3954
4053
|
return AGENT_STATES.includes(value);
|
|
3955
4054
|
}
|
|
3956
4055
|
function parseLiveSessionReports(output) {
|
|
3957
4056
|
const reports = /* @__PURE__ */ new Map();
|
|
3958
4057
|
for (const line of output.split("\n")) {
|
|
3959
|
-
const [
|
|
4058
|
+
const [
|
|
4059
|
+
sessionName,
|
|
4060
|
+
agentState = "",
|
|
4061
|
+
bell = "",
|
|
4062
|
+
setupFailed = "",
|
|
4063
|
+
setupSkipped = "",
|
|
4064
|
+
pullRequests = "",
|
|
4065
|
+
...headline
|
|
4066
|
+
] = line.split(" ");
|
|
3960
4067
|
if (sessionName) {
|
|
3961
4068
|
reports.set(sessionName, {
|
|
3962
4069
|
agentState,
|
|
3963
4070
|
bell: bell === "1",
|
|
3964
4071
|
setupFailed: setupFailed === "1",
|
|
4072
|
+
setupSkipped: decodeSkippedFiles(setupSkipped),
|
|
4073
|
+
pullRequestUrls: parsePullRequestUrls(pullRequests),
|
|
3965
4074
|
headline: cleanHeadline(headline.join(" "))
|
|
3966
4075
|
});
|
|
3967
4076
|
}
|
|
3968
4077
|
}
|
|
3969
4078
|
return reports;
|
|
3970
4079
|
}
|
|
3971
|
-
function
|
|
4080
|
+
function parseReport(raw) {
|
|
3972
4081
|
if (!raw) return null;
|
|
3973
4082
|
const [state, epoch] = raw.split(":");
|
|
3974
4083
|
if (!state || !isAgentState(state) || !epoch || !/^\d+$/.test(epoch)) return null;
|
|
3975
|
-
|
|
4084
|
+
return { state, reportedAtMs: Number(epoch) * 1e3 };
|
|
4085
|
+
}
|
|
4086
|
+
function resolveAgentState(raw, lastActivityAt, nowMs = Date.now()) {
|
|
4087
|
+
const parsed = parseReport(raw);
|
|
4088
|
+
if (!parsed) return null;
|
|
4089
|
+
const { state, reportedAtMs } = parsed;
|
|
3976
4090
|
if (reportedAtMs > nowMs + FUTURE_REPORT_ALLOWANCE_MS) return null;
|
|
3977
4091
|
if (SETTLED_STATES.has(state) && lastActivityAt) {
|
|
3978
4092
|
if (Date.parse(lastActivityAt) > reportedAtMs + AGENT_STATE_OUTPUT_GRACE_MS) return null;
|
|
@@ -3992,9 +4106,16 @@ function hasSettled(lastActivityAt, acknowledgedAtMs, nowMs = Date.now()) {
|
|
|
3992
4106
|
}
|
|
3993
4107
|
return nowMs - lastActivityMs >= SETTLE_QUIET_MS;
|
|
3994
4108
|
}
|
|
4109
|
+
var REPORT_ROUNDING_MS = 1e3;
|
|
4110
|
+
function isDoneAcknowledged(raw, acknowledgedAtMs) {
|
|
4111
|
+
const reportedAtMs = parseReport(raw)?.reportedAtMs;
|
|
4112
|
+
if (acknowledgedAtMs === null || reportedAtMs === void 0) return false;
|
|
4113
|
+
return acknowledgedAtMs >= reportedAtMs + REPORT_ROUNDING_MS;
|
|
4114
|
+
}
|
|
3995
4115
|
function resolveSessionAttention(report, lastActivityAt, { nowMs, acknowledgedAtMs = null } = {}) {
|
|
3996
4116
|
if (report?.bell) return "waiting";
|
|
3997
4117
|
const reported = resolveAgentState(report?.agentState, lastActivityAt, nowMs);
|
|
4118
|
+
if (reported === "done" && isDoneAcknowledged(report?.agentState, acknowledgedAtMs)) return null;
|
|
3998
4119
|
if (reported) return reported;
|
|
3999
4120
|
return hasSettled(lastActivityAt, acknowledgedAtMs, nowMs) ? "waiting" : null;
|
|
4000
4121
|
}
|
|
@@ -4045,6 +4166,21 @@ function headlineHookScript() {
|
|
|
4045
4166
|
`}catch{}})`
|
|
4046
4167
|
].join("");
|
|
4047
4168
|
}
|
|
4169
|
+
var PR_CREATE_COMMAND = /(?:^|[;&|\n(])\s*(?:\w+=\S*\s+)*gh\s+pr\s+create\b/;
|
|
4170
|
+
function pullRequestHookScript() {
|
|
4171
|
+
return [
|
|
4172
|
+
`let d="";`,
|
|
4173
|
+
`process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
|
|
4174
|
+
`const j=JSON.parse(d),c=j.tool_input?.command,o=j.tool_response?.stdout;`,
|
|
4175
|
+
`if(typeof c!=="string"||typeof o!=="string"||!${PR_CREATE_COMMAND.toString()}.test(c))return;`,
|
|
4176
|
+
`const m=o.match(${PULL_REQUEST_URL.toString()});if(!m)return;const u=m[m.length-1];`,
|
|
4177
|
+
`const cp=require("child_process"),p=process.env.TMUX_PANE;`,
|
|
4178
|
+
`const cur=cp.execFileSync("tmux",["show-options","-qv","-t",p,"${PULL_REQUESTS_OPTION}"],{encoding:"utf8"}).split(/\\s+/).filter(Boolean);`,
|
|
4179
|
+
`if(cur.includes(u))return;`,
|
|
4180
|
+
`cp.execFileSync("tmux",["set-option","-t",p,"-q","${PULL_REQUESTS_OPTION}",cur.concat(u).slice(-${MAX_SESSION_PULL_REQUESTS}).join(" ")])`,
|
|
4181
|
+
`}catch{}})`
|
|
4182
|
+
].join("");
|
|
4183
|
+
}
|
|
4048
4184
|
|
|
4049
4185
|
// src/sessions/agents/claude-settings.ts
|
|
4050
4186
|
function shellQuote3(value) {
|
|
@@ -4059,12 +4195,17 @@ function planHookCommand(nodePath = process.execPath, plansDir = HOOK_PLANS_DIR)
|
|
|
4059
4195
|
function headlineHookCommand(nodePath = process.execPath) {
|
|
4060
4196
|
return nodeHookCommand(headlineHookScript(), nodePath);
|
|
4061
4197
|
}
|
|
4198
|
+
function pullRequestHookCommand(nodePath = process.execPath) {
|
|
4199
|
+
const run = `${shellQuote3(nodePath)} -e ${shellQuote3(pullRequestHookScript())}`;
|
|
4200
|
+
return `[ -n "$${SESSION_ENV_VAR}" ] && { input=$(cat); case "$input" in *"gh pr create"*) printf '%s' "$input" | ${run};; esac; }; exit 0`;
|
|
4201
|
+
}
|
|
4062
4202
|
function stateHookCommand(state) {
|
|
4063
4203
|
return `[ -n "$${SESSION_ENV_VAR}" ] && tmux set-option -t "$TMUX_PANE" -q ${AGENT_STATE_OPTION} "${state}:$(date +%s)"; exit 0`;
|
|
4064
4204
|
}
|
|
4065
4205
|
var STATE_HOOK_TIMEOUT_SECONDS = 5;
|
|
4066
4206
|
var PLAN_HOOK_TIMEOUT_SECONDS = 10;
|
|
4067
4207
|
var HEADLINE_HOOK_TIMEOUT_SECONDS = 5;
|
|
4208
|
+
var PULL_REQUEST_HOOK_TIMEOUT_SECONDS = 5;
|
|
4068
4209
|
function claudeSettings() {
|
|
4069
4210
|
const run = (command, timeout) => ({ type: "command", command, timeout });
|
|
4070
4211
|
const report = (state) => run(stateHookCommand(state), STATE_HOOK_TIMEOUT_SECONDS);
|
|
@@ -4074,16 +4215,20 @@ function claudeSettings() {
|
|
|
4074
4215
|
{
|
|
4075
4216
|
matcher: "ExitPlanMode",
|
|
4076
4217
|
hooks: [run(planHookCommand(), PLAN_HOOK_TIMEOUT_SECONDS)]
|
|
4218
|
+
},
|
|
4219
|
+
{
|
|
4220
|
+
matcher: "Bash",
|
|
4221
|
+
hooks: [run(pullRequestHookCommand(), PULL_REQUEST_HOOK_TIMEOUT_SECONDS)]
|
|
4077
4222
|
}
|
|
4078
4223
|
],
|
|
4079
|
-
// Working from the moment a prompt is sent, which is also the task the session is on; waiting
|
|
4224
|
+
// Working from the moment a prompt is sent, which is also the task the session is on; waiting when it needs the user; done when a turn ends.
|
|
4080
4225
|
UserPromptSubmit: [
|
|
4081
4226
|
{
|
|
4082
4227
|
hooks: [report("working"), run(headlineHookCommand(), HEADLINE_HOOK_TIMEOUT_SECONDS)]
|
|
4083
4228
|
}
|
|
4084
4229
|
],
|
|
4085
4230
|
PermissionRequest: [{ matcher: "*", hooks: [report("waiting")] }],
|
|
4086
|
-
Stop: [{ hooks: [report("
|
|
4231
|
+
Stop: [{ hooks: [report("done")] }],
|
|
4087
4232
|
StopFailure: [{ hooks: [report("waiting")] }]
|
|
4088
4233
|
}
|
|
4089
4234
|
};
|
|
@@ -4190,19 +4335,74 @@ async function resolveWorktreeStartPoint(sourceCwd, baseBranch) {
|
|
|
4190
4335
|
import { constants as constants4 } from "fs";
|
|
4191
4336
|
import { copyFile, glob, lstat as lstat3, mkdir as mkdir5, realpath as realpath2 } from "fs/promises";
|
|
4192
4337
|
import { dirname as dirname4, isAbsolute as isAbsolute3, join as join10, relative, sep as sep2 } from "path";
|
|
4338
|
+
var GIT_EXIT_NONE_IGNORED = 1;
|
|
4339
|
+
var TRACKED_CHECK_BATCH = 200;
|
|
4193
4340
|
function isInside(root, path) {
|
|
4194
4341
|
const rel = relative(root, path);
|
|
4195
4342
|
return rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute3(rel);
|
|
4196
4343
|
}
|
|
4344
|
+
async function ignoredPaths(worktree, paths) {
|
|
4345
|
+
if (paths.length === 0) return /* @__PURE__ */ new Set();
|
|
4346
|
+
const output = await runGitWithInput(
|
|
4347
|
+
["check-ignore", "--stdin", "-z"],
|
|
4348
|
+
worktree,
|
|
4349
|
+
paths.map((path) => `${path}\0`).join(""),
|
|
4350
|
+
{ allowedExitCodes: [GIT_EXIT_NONE_IGNORED] }
|
|
4351
|
+
);
|
|
4352
|
+
return new Set(output.split("\0").filter((path) => path !== ""));
|
|
4353
|
+
}
|
|
4354
|
+
async function trackedPaths(worktree, paths) {
|
|
4355
|
+
const tracked = /* @__PURE__ */ new Set();
|
|
4356
|
+
for (let start2 = 0; start2 < paths.length; start2 += TRACKED_CHECK_BATCH) {
|
|
4357
|
+
const output = await runGit(
|
|
4358
|
+
[
|
|
4359
|
+
"--literal-pathspecs",
|
|
4360
|
+
"ls-files",
|
|
4361
|
+
"-z",
|
|
4362
|
+
"--",
|
|
4363
|
+
...paths.slice(start2, start2 + TRACKED_CHECK_BATCH)
|
|
4364
|
+
],
|
|
4365
|
+
worktree
|
|
4366
|
+
);
|
|
4367
|
+
for (const path of output.split("\0")) if (path !== "") tracked.add(path);
|
|
4368
|
+
}
|
|
4369
|
+
return tracked;
|
|
4370
|
+
}
|
|
4197
4371
|
async function copyFilesIntoWorktree(sourceRoot, worktreeRoot, globs) {
|
|
4198
|
-
const result = { copied: [], failed: 0 };
|
|
4372
|
+
const result = { copied: [], failed: 0, skipped: [] };
|
|
4199
4373
|
const patterns = globs.filter(isSafeCopyGlob);
|
|
4200
4374
|
if (patterns.length === 0) return result;
|
|
4201
4375
|
try {
|
|
4202
4376
|
const source = await realpath2(sourceRoot);
|
|
4203
4377
|
const worktree = await realpath2(worktreeRoot);
|
|
4378
|
+
const candidates = [];
|
|
4204
4379
|
for await (const match of glob(patterns, { cwd: source })) {
|
|
4205
4380
|
if (match.split(sep2).includes(".git")) continue;
|
|
4381
|
+
try {
|
|
4382
|
+
if (await isCopyCandidate(source, worktree, match)) candidates.push(match);
|
|
4383
|
+
} catch (error2) {
|
|
4384
|
+
result.failed += 1;
|
|
4385
|
+
console.error("Failed to inspect a file to copy into the worktree", { match, error: error2 });
|
|
4386
|
+
}
|
|
4387
|
+
}
|
|
4388
|
+
let tracked;
|
|
4389
|
+
let ignored;
|
|
4390
|
+
try {
|
|
4391
|
+
tracked = await trackedPaths(worktree, candidates);
|
|
4392
|
+
ignored = await ignoredPaths(worktree, candidates);
|
|
4393
|
+
} catch (error2) {
|
|
4394
|
+
result.failed += candidates.length;
|
|
4395
|
+
console.error("Could not ask git what the worktree tracks and ignores; copying nothing", {
|
|
4396
|
+
error: error2
|
|
4397
|
+
});
|
|
4398
|
+
return result;
|
|
4399
|
+
}
|
|
4400
|
+
for (const match of candidates) {
|
|
4401
|
+
if (tracked.has(match)) continue;
|
|
4402
|
+
if (!ignored.has(match)) {
|
|
4403
|
+
result.skipped.push(match);
|
|
4404
|
+
continue;
|
|
4405
|
+
}
|
|
4206
4406
|
try {
|
|
4207
4407
|
if (await copyOne(source, worktree, match)) result.copied.push(match);
|
|
4208
4408
|
} catch (error2) {
|
|
@@ -4216,15 +4416,24 @@ async function copyFilesIntoWorktree(sourceRoot, worktreeRoot, globs) {
|
|
|
4216
4416
|
}
|
|
4217
4417
|
return result;
|
|
4218
4418
|
}
|
|
4219
|
-
async function
|
|
4419
|
+
async function isCopyCandidate(source, worktree, match) {
|
|
4220
4420
|
const from = join10(source, match);
|
|
4221
4421
|
if (!(await lstat3(from)).isFile()) return false;
|
|
4222
4422
|
if (!isInside(source, await realpath2(from))) return false;
|
|
4423
|
+
try {
|
|
4424
|
+
await lstat3(join10(worktree, match));
|
|
4425
|
+
return false;
|
|
4426
|
+
} catch (error2) {
|
|
4427
|
+
if (error2.code === "ENOENT") return true;
|
|
4428
|
+
throw error2;
|
|
4429
|
+
}
|
|
4430
|
+
}
|
|
4431
|
+
async function copyOne(source, worktree, match) {
|
|
4223
4432
|
const to = join10(worktree, match);
|
|
4224
4433
|
await mkdir5(dirname4(to), { recursive: true });
|
|
4225
4434
|
if (!isInside(worktree, await realpath2(dirname4(to)))) return false;
|
|
4226
4435
|
try {
|
|
4227
|
-
await copyFile(
|
|
4436
|
+
await copyFile(join10(source, match), to, constants4.COPYFILE_EXCL);
|
|
4228
4437
|
} catch (error2) {
|
|
4229
4438
|
if (error2.code === "EEXIST") return false;
|
|
4230
4439
|
throw error2;
|
|
@@ -4247,6 +4456,7 @@ async function createTmuxSession(input) {
|
|
|
4247
4456
|
let cwd = sourceCwd;
|
|
4248
4457
|
let worktreeOf = null;
|
|
4249
4458
|
let setupFailed = false;
|
|
4459
|
+
let skippedFiles = [];
|
|
4250
4460
|
if (input.worktreeBranch) {
|
|
4251
4461
|
const worktreePath = worktreePathFor(input.id);
|
|
4252
4462
|
const startPoint = await resolveWorktreeStartPoint(sourceCwd, input.baseBranch);
|
|
@@ -4260,6 +4470,7 @@ async function createTmuxSession(input) {
|
|
|
4260
4470
|
input.worktreeSetup.copyGlobs
|
|
4261
4471
|
);
|
|
4262
4472
|
setupFailed = copy.failed > 0;
|
|
4473
|
+
skippedFiles = copy.skipped;
|
|
4263
4474
|
}
|
|
4264
4475
|
}
|
|
4265
4476
|
try {
|
|
@@ -4297,6 +4508,15 @@ async function createTmuxSession(input) {
|
|
|
4297
4508
|
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-worktree-of", worktreeOf]);
|
|
4298
4509
|
}
|
|
4299
4510
|
if (setupFailed) await runTmux(["set-option", "-t", tmuxSessionName, SETUP_FAILED_OPTION, "1"]);
|
|
4511
|
+
if (skippedFiles.length > 0) {
|
|
4512
|
+
await runTmux([
|
|
4513
|
+
"set-option",
|
|
4514
|
+
"-t",
|
|
4515
|
+
tmuxSessionName,
|
|
4516
|
+
SETUP_SKIPPED_OPTION,
|
|
4517
|
+
encodeSkippedFiles(skippedFiles)
|
|
4518
|
+
]);
|
|
4519
|
+
}
|
|
4300
4520
|
if (baseline) {
|
|
4301
4521
|
await runTmux([
|
|
4302
4522
|
"set-option",
|
|
@@ -4319,7 +4539,10 @@ async function createTmuxSession(input) {
|
|
|
4319
4539
|
lastActivityAt: null,
|
|
4320
4540
|
changedFileCount: null,
|
|
4321
4541
|
worktreeOf,
|
|
4322
|
-
...setupFailed && { worktreeSetupFailed: true }
|
|
4542
|
+
...setupFailed && { worktreeSetupFailed: true },
|
|
4543
|
+
...skippedFiles.length > 0 && {
|
|
4544
|
+
worktreeSetupSkipped: skippedFiles.slice(0, MAX_SKIPPED_FILES_REPORTED)
|
|
4545
|
+
}
|
|
4323
4546
|
};
|
|
4324
4547
|
return { session: await enrichSessionWithGitMetadata(session, baseline), baseline };
|
|
4325
4548
|
}
|
|
@@ -4515,17 +4738,16 @@ ${error2.message}
|
|
|
4515
4738
|
session,
|
|
4516
4739
|
this.baselines.get(session.id)
|
|
4517
4740
|
);
|
|
4741
|
+
const report = agentReports.get(session.tmuxSessionName);
|
|
4518
4742
|
const updated = {
|
|
4519
4743
|
...enriched,
|
|
4520
|
-
agentState: resolveSessionAttention(
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
...
|
|
4527
|
-
worktreeSetupFailed: true
|
|
4528
|
-
}
|
|
4744
|
+
agentState: resolveSessionAttention(report, enriched.lastActivityAt, {
|
|
4745
|
+
acknowledgedAtMs: this.acknowledgements.get(session.id) ?? null
|
|
4746
|
+
}),
|
|
4747
|
+
headline: report?.headline ?? null,
|
|
4748
|
+
...report?.pullRequestUrls?.length && { pullRequestUrls: report.pullRequestUrls },
|
|
4749
|
+
...report?.setupFailed && { worktreeSetupFailed: true },
|
|
4750
|
+
...report?.setupSkipped?.length && { worktreeSetupSkipped: report.setupSkipped }
|
|
4529
4751
|
};
|
|
4530
4752
|
if (!snapshotEquals(session, updated)) {
|
|
4531
4753
|
this.sessions.set(session.id, updated);
|
|
@@ -4550,7 +4772,7 @@ ${error2.message}
|
|
|
4550
4772
|
}
|
|
4551
4773
|
};
|
|
4552
4774
|
function snapshotEquals(a, b) {
|
|
4553
|
-
return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt && (a.agentState ?? null) === (b.agentState ?? null) && (a.headline ?? null) === (b.headline ?? null) && (a.worktreeSetupFailed ?? false) === (b.worktreeSetupFailed ?? false);
|
|
4775
|
+
return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt && (a.agentState ?? null) === (b.agentState ?? null) && (a.headline ?? null) === (b.headline ?? null) && JSON.stringify(a.pullRequestUrls ?? []) === JSON.stringify(b.pullRequestUrls ?? []) && (a.worktreeSetupFailed ?? false) === (b.worktreeSetupFailed ?? false) && JSON.stringify(a.worktreeSetupSkipped ?? []) === JSON.stringify(b.worktreeSetupSkipped ?? []);
|
|
4554
4776
|
}
|
|
4555
4777
|
|
|
4556
4778
|
// src/ws/server.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bondsmith/bondsmithd",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
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",
|