@bondsmith/bondsmithd 0.1.14 → 0.1.16
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 +43 -15
- package/dist/index.js +557 -243
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4,20 +4,23 @@
|
|
|
4
4
|
import { createServer } from "http";
|
|
5
5
|
import { existsSync } from "fs";
|
|
6
6
|
import { hostname as hostname2 } from "os";
|
|
7
|
-
import { dirname as
|
|
7
|
+
import { dirname as dirname6, join as join13 } 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
12
|
var AGENT_STATES = ["working", "waiting", "done"];
|
|
13
|
+
var AGENT_IDS = ["claude", "codex", "opencode"];
|
|
14
|
+
var TASK_STATUSES = ["pending", "in_progress", "completed"];
|
|
13
15
|
var MAX_SKIPPED_FILES_REPORTED = 20;
|
|
16
|
+
var MAX_SESSION_PULL_REQUESTS = 20;
|
|
14
17
|
|
|
15
18
|
// ../../packages/local-agent-protocol/src/messages.ts
|
|
16
19
|
var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
|
|
17
20
|
var INVALID_MESSAGE_ERROR = "invalid message";
|
|
18
21
|
|
|
19
22
|
// ../../packages/local-agent-protocol/src/protocol-version.ts
|
|
20
|
-
var PROTOCOL_VERSION =
|
|
23
|
+
var PROTOCOL_VERSION = 20;
|
|
21
24
|
|
|
22
25
|
// ../../packages/local-agent-protocol/src/branch-name.ts
|
|
23
26
|
var MAX_BRANCH_NAME_LENGTH = 250;
|
|
@@ -184,6 +187,8 @@ var MAX_WIRE_MESSAGE_BYTES = 256 * 1024;
|
|
|
184
187
|
var MAX_DAEMON_REPLY_BYTES = 200 * 1024;
|
|
185
188
|
var MAX_RANGE_LINES = 500;
|
|
186
189
|
var MAX_PLAN_CHARS = 32 * 1024;
|
|
190
|
+
var MAX_PLAN_TASKS = 50;
|
|
191
|
+
var MAX_TASK_TEXT_CHARS = 120;
|
|
187
192
|
var MAX_TERMINAL_DIMENSION = 1e3;
|
|
188
193
|
var terminalSizeSchema = z3.object({
|
|
189
194
|
cols: z3.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
|
|
@@ -204,9 +209,10 @@ var localSessionSchema = z3.object({
|
|
|
204
209
|
worktreeOf: z3.string().nullable(),
|
|
205
210
|
// .catch(undefined): a state this version doesn't know must never drop the whole session message.
|
|
206
211
|
agentState: z3.enum(AGENT_STATES).nullable().optional().catch(void 0),
|
|
212
|
+
agent: z3.enum(AGENT_IDS).nullable().optional().catch(void 0),
|
|
207
213
|
worktreeSetupFailed: z3.boolean().optional().catch(void 0),
|
|
208
214
|
worktreeSetupSkipped: z3.array(z3.string().max(300)).max(MAX_SKIPPED_FILES_REPORTED).optional().catch(void 0),
|
|
209
|
-
|
|
215
|
+
pullRequestUrls: z3.array(z3.string().max(300)).max(MAX_SESSION_PULL_REQUESTS).optional().catch(void 0)
|
|
210
216
|
});
|
|
211
217
|
var workspaceFileEntrySchema = z3.object({
|
|
212
218
|
path: z3.string(),
|
|
@@ -317,6 +323,11 @@ var browserMessageSchema = z3.discriminatedUnion("type", [
|
|
|
317
323
|
sessionId: z3.string(),
|
|
318
324
|
...terminalSizeSchema.shape
|
|
319
325
|
}),
|
|
326
|
+
z3.object({
|
|
327
|
+
type: z3.literal("session.setMouseMode"),
|
|
328
|
+
sessionId: z3.string(),
|
|
329
|
+
enabled: z3.boolean()
|
|
330
|
+
}),
|
|
320
331
|
z3.object({ type: z3.literal("session.stop"), sessionId: z3.string() }),
|
|
321
332
|
z3.object({
|
|
322
333
|
type: z3.literal("workspace.getStatus"),
|
|
@@ -381,6 +392,11 @@ var connectionAuthenticationFailureReasonSchema = z3.enum([
|
|
|
381
392
|
"key_agreement_failed"
|
|
382
393
|
]);
|
|
383
394
|
var connectionRejectionReasonSchema = z3.enum(["origin_not_allowed", "token_invalid"]);
|
|
395
|
+
var sessionTaskSchema = z3.object({
|
|
396
|
+
subject: z3.string().max(MAX_TASK_TEXT_CHARS),
|
|
397
|
+
activeForm: z3.string().max(MAX_TASK_TEXT_CHARS).optional(),
|
|
398
|
+
status: z3.enum(TASK_STATUSES)
|
|
399
|
+
});
|
|
384
400
|
var daemonMessageSchema = z3.discriminatedUnion("type", [
|
|
385
401
|
z3.object({ type: z3.literal("session.list"), sessions: z3.array(localSessionSchema) }),
|
|
386
402
|
z3.object({
|
|
@@ -438,7 +454,8 @@ var daemonMessageSchema = z3.discriminatedUnion("type", [
|
|
|
438
454
|
type: z3.literal("workspace.plan"),
|
|
439
455
|
sessionId: z3.string(),
|
|
440
456
|
requestId: z3.string(),
|
|
441
|
-
plan: z3.string().max(MAX_PLAN_CHARS).nullable()
|
|
457
|
+
plan: z3.string().max(MAX_PLAN_CHARS).nullable(),
|
|
458
|
+
tasks: z3.array(sessionTaskSchema).max(MAX_PLAN_TASKS).default([])
|
|
442
459
|
}),
|
|
443
460
|
z3.object({
|
|
444
461
|
type: z3.literal("project.pathValidated"),
|
|
@@ -527,6 +544,7 @@ var SIGNABLE_ACTIONS = [
|
|
|
527
544
|
"session.input",
|
|
528
545
|
"session.insertText",
|
|
529
546
|
"session.resize",
|
|
547
|
+
"session.setMouseMode",
|
|
530
548
|
"session.stop",
|
|
531
549
|
"session.list",
|
|
532
550
|
"workspace.getStatus",
|
|
@@ -1035,7 +1053,7 @@ async function callTRPCQuery(baseUrl, procedure, input) {
|
|
|
1035
1053
|
// src/pairing/pairing.ts
|
|
1036
1054
|
var DEFAULT_POLL_INTERVAL_MS = 3e3;
|
|
1037
1055
|
function sleep(ms) {
|
|
1038
|
-
return new Promise((
|
|
1056
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1039
1057
|
}
|
|
1040
1058
|
async function ensurePaired(options) {
|
|
1041
1059
|
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
@@ -1173,8 +1191,8 @@ async function bindFirstController(params, response) {
|
|
|
1173
1191
|
}
|
|
1174
1192
|
|
|
1175
1193
|
// src/projects/validate-path.ts
|
|
1176
|
-
import { stat
|
|
1177
|
-
import { isAbsolute
|
|
1194
|
+
import { stat } from "fs/promises";
|
|
1195
|
+
import { isAbsolute, resolve } from "path";
|
|
1178
1196
|
|
|
1179
1197
|
// src/sessions/expand-home.ts
|
|
1180
1198
|
import { homedir } from "os";
|
|
@@ -1244,7 +1262,7 @@ function runGitDiffAllowingExitCodes(args, cwd, allowedExitCodes) {
|
|
|
1244
1262
|
var GIT_INPUT_TIMEOUT_MS = 3e4;
|
|
1245
1263
|
async function runGitWithInput(args, cwd, input, options = {}) {
|
|
1246
1264
|
const { allowedExitCodes = [], timeoutMs = GIT_INPUT_TIMEOUT_MS } = options;
|
|
1247
|
-
return new Promise((
|
|
1265
|
+
return new Promise((resolve3, reject) => {
|
|
1248
1266
|
const child = spawn2("git", [...SAFE_GIT_CONFIG, ...args], { cwd });
|
|
1249
1267
|
const stdout = [];
|
|
1250
1268
|
const stderr = [];
|
|
@@ -1268,7 +1286,7 @@ async function runGitWithInput(args, cwd, input, options = {}) {
|
|
|
1268
1286
|
"close",
|
|
1269
1287
|
(code) => settle(() => {
|
|
1270
1288
|
if (code === 0 || code !== null && allowedExitCodes.includes(code)) {
|
|
1271
|
-
|
|
1289
|
+
resolve3(Buffer.concat(stdout).toString("utf8"));
|
|
1272
1290
|
return;
|
|
1273
1291
|
}
|
|
1274
1292
|
const message = Buffer.concat(stderr).toString("utf8").trim();
|
|
@@ -1279,134 +1297,13 @@ async function runGitWithInput(args, cwd, input, options = {}) {
|
|
|
1279
1297
|
});
|
|
1280
1298
|
}
|
|
1281
1299
|
|
|
1282
|
-
// src/sessions/
|
|
1283
|
-
import { constants as constants2 } from "fs";
|
|
1284
|
-
import { open as open2 } from "fs/promises";
|
|
1300
|
+
// src/sessions/agent-reports/layout.ts
|
|
1285
1301
|
import { homedir as homedir2 } from "os";
|
|
1286
|
-
import { join as
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
import { lstat, open, realpath, stat } from "fs/promises";
|
|
1291
|
-
import { isAbsolute, join as join2, sep } from "path";
|
|
1292
|
-
var MAX_RANGE_FILE_BYTES = 2 * 1024 * 1024;
|
|
1293
|
-
var REPLY_ENVELOPE_BYTES = 8 * 1024;
|
|
1294
|
-
var BINARY_SNIFF_BYTES = 8e3;
|
|
1295
|
-
function fileVersion(stats) {
|
|
1296
|
-
return `${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}:${stats.ino}`;
|
|
1297
|
-
}
|
|
1298
|
-
async function locateInside(cwd, path) {
|
|
1299
|
-
if (isAbsolute(path)) return { refused: "not_allowed" };
|
|
1300
|
-
try {
|
|
1301
|
-
const root = await realpath(cwd);
|
|
1302
|
-
const lexical = join2(root, path);
|
|
1303
|
-
if ((await lstat(lexical)).isSymbolicLink()) return { refused: "not_allowed" };
|
|
1304
|
-
const real = await realpath(lexical);
|
|
1305
|
-
return real === root || real.startsWith(root + sep) ? { found: real } : { refused: "missing" };
|
|
1306
|
-
} catch {
|
|
1307
|
-
return { refused: "missing" };
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
async function readExactly(handle, size) {
|
|
1311
|
-
const buffer = Buffer.allocUnsafe(size + 1);
|
|
1312
|
-
let filled = 0;
|
|
1313
|
-
while (filled < buffer.length) {
|
|
1314
|
-
const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
|
|
1315
|
-
if (bytesRead === 0) break;
|
|
1316
|
-
filled += bytesRead;
|
|
1317
|
-
}
|
|
1318
|
-
return filled === size ? buffer.subarray(0, size) : void 0;
|
|
1319
|
-
}
|
|
1320
|
-
async function currentFileVersion(cwd, path) {
|
|
1321
|
-
const located = await locateInside(cwd, path);
|
|
1322
|
-
if (!("found" in located)) return void 0;
|
|
1323
|
-
try {
|
|
1324
|
-
return fileVersion(await stat(located.found));
|
|
1325
|
-
} catch {
|
|
1326
|
-
return void 0;
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
function splitLines(text) {
|
|
1330
|
-
if (text === "") return [];
|
|
1331
|
-
const lines = text.split("\n");
|
|
1332
|
-
if (lines[lines.length - 1] === "") lines.pop();
|
|
1333
|
-
return lines.map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
1334
|
-
}
|
|
1335
|
-
async function readFileRange(cwd, path, startLine, lineCount, version) {
|
|
1336
|
-
const refuse = (reason) => ({ ok: false, path, reason });
|
|
1337
|
-
const located = await locateInside(cwd, path);
|
|
1338
|
-
if (!("found" in located)) return refuse(located.refused);
|
|
1339
|
-
let content;
|
|
1340
|
-
let handle;
|
|
1341
|
-
try {
|
|
1342
|
-
handle = await open(
|
|
1343
|
-
located.found,
|
|
1344
|
-
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK
|
|
1345
|
-
);
|
|
1346
|
-
const before = await handle.stat();
|
|
1347
|
-
if (!before.isFile()) return refuse("not_allowed");
|
|
1348
|
-
if (fileVersion(before) !== version) return refuse("changed");
|
|
1349
|
-
if (before.size > MAX_RANGE_FILE_BYTES) return refuse("too_large");
|
|
1350
|
-
const bytes = await readExactly(handle, before.size);
|
|
1351
|
-
if (bytes === void 0 || fileVersion(await handle.stat()) !== version) {
|
|
1352
|
-
return refuse("changed");
|
|
1353
|
-
}
|
|
1354
|
-
content = bytes;
|
|
1355
|
-
} catch {
|
|
1356
|
-
return refuse("missing");
|
|
1357
|
-
} finally {
|
|
1358
|
-
await handle?.close();
|
|
1359
|
-
}
|
|
1360
|
-
if (content.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return refuse("binary");
|
|
1361
|
-
const lines = splitLines(content.toString("utf8"));
|
|
1362
|
-
const page = [];
|
|
1363
|
-
let budget = MAX_DAEMON_REPLY_BYTES - REPLY_ENVELOPE_BYTES;
|
|
1364
|
-
const wanted = Math.min(lineCount, MAX_RANGE_LINES);
|
|
1365
|
-
for (let index = startLine - 1; index < lines.length && page.length < wanted; index++) {
|
|
1366
|
-
const line = lines[index];
|
|
1367
|
-
const cost = Buffer.byteLength(JSON.stringify(line)) + 1;
|
|
1368
|
-
if (cost > budget) {
|
|
1369
|
-
if (page.length === 0) return refuse("too_large");
|
|
1370
|
-
break;
|
|
1371
|
-
}
|
|
1372
|
-
budget -= cost;
|
|
1373
|
-
page.push(line);
|
|
1374
|
-
}
|
|
1375
|
-
return { ok: true, path, startLine, lines: page, totalLines: lines.length, version };
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
// src/sessions/files/plan-file.ts
|
|
1302
|
+
import { join as join2 } from "path";
|
|
1303
|
+
var HOOK_PLANS_DIR = join2(homedir2(), ".bondsmith", "plans");
|
|
1304
|
+
var PLAN_FILE_SUFFIX = ".md";
|
|
1305
|
+
var TASKS_FILE_SUFFIX = ".tasks.jsonl";
|
|
1379
1306
|
var PLAN_FILE_PATH = ".bondsmith/plan.md";
|
|
1380
|
-
var MAX_PLAN_BYTES = MAX_PLAN_CHARS * 4;
|
|
1381
|
-
var ABSENT_CODES = /* @__PURE__ */ new Set(["ENOENT", "ELOOP", "ENOTDIR"]);
|
|
1382
|
-
var HOOK_PLANS_DIR = join3(homedir2(), ".bondsmith", "plans");
|
|
1383
|
-
async function readPlanAt(root, relativePath) {
|
|
1384
|
-
const located = await locateInside(root, relativePath);
|
|
1385
|
-
if (!("found" in located)) return null;
|
|
1386
|
-
let handle;
|
|
1387
|
-
try {
|
|
1388
|
-
handle = await open2(
|
|
1389
|
-
located.found,
|
|
1390
|
-
constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK
|
|
1391
|
-
);
|
|
1392
|
-
if (!(await handle.stat()).isFile()) return null;
|
|
1393
|
-
const buffer = Buffer.alloc(MAX_PLAN_BYTES);
|
|
1394
|
-
const { bytesRead } = await handle.read(buffer, 0, MAX_PLAN_BYTES, 0);
|
|
1395
|
-
const plan = buffer.toString("utf8", 0, bytesRead).trim();
|
|
1396
|
-
return plan ? plan.slice(0, MAX_PLAN_CHARS) : null;
|
|
1397
|
-
} catch (error2) {
|
|
1398
|
-
if (ABSENT_CODES.has(error2.code ?? "")) return null;
|
|
1399
|
-
throw error2;
|
|
1400
|
-
} finally {
|
|
1401
|
-
await handle?.close();
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
function readPlanFile(cwd) {
|
|
1405
|
-
return readPlanAt(cwd, PLAN_FILE_PATH);
|
|
1406
|
-
}
|
|
1407
|
-
function readHookPlan(sessionId, plansDir = HOOK_PLANS_DIR) {
|
|
1408
|
-
return readPlanAt(plansDir, `${sessionId}.md`);
|
|
1409
|
-
}
|
|
1410
1307
|
|
|
1411
1308
|
// src/sessions/tmux/client.ts
|
|
1412
1309
|
import { execFile as execFileCb2 } from "child_process";
|
|
@@ -1454,8 +1351,14 @@ async function runTmux(args, options) {
|
|
|
1454
1351
|
throw error2;
|
|
1455
1352
|
}
|
|
1456
1353
|
}
|
|
1457
|
-
async function sendKeysLiteral(tmuxSessionName,
|
|
1458
|
-
await runTmux(["send-keys", "-t", tmuxSessionName, "-l",
|
|
1354
|
+
async function sendKeysLiteral(tmuxSessionName, text2) {
|
|
1355
|
+
await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text2]);
|
|
1356
|
+
}
|
|
1357
|
+
async function setMouseMode(tmuxSessionName, enabled) {
|
|
1358
|
+
await runTmux(["set-option", "-t", tmuxSessionName, "mouse", enabled ? "on" : "off"]);
|
|
1359
|
+
}
|
|
1360
|
+
async function cancelCopyMode(tmuxSessionName) {
|
|
1361
|
+
await runTmux(["copy-mode", "-q", "-t", tmuxSessionName]);
|
|
1459
1362
|
}
|
|
1460
1363
|
async function capturePane(tmuxSessionName) {
|
|
1461
1364
|
return runTmux(["capture-pane", "-t", tmuxSessionName, "-p", "-e"]);
|
|
@@ -1493,8 +1396,8 @@ var NO_SERVER_MESSAGE_PATTERNS = [
|
|
|
1493
1396
|
function isNoServerError(error2) {
|
|
1494
1397
|
if (typeof error2 !== "object" || error2 === null) return false;
|
|
1495
1398
|
const { message, stderr } = error2;
|
|
1496
|
-
const
|
|
1497
|
-
return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(
|
|
1399
|
+
const text2 = [message, stderr].filter((part) => typeof part === "string").join("\n");
|
|
1400
|
+
return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(text2));
|
|
1498
1401
|
}
|
|
1499
1402
|
|
|
1500
1403
|
// src/sessions/git/base.ts
|
|
@@ -1531,14 +1434,14 @@ async function resolveComparisonBase(cwd) {
|
|
|
1531
1434
|
}
|
|
1532
1435
|
|
|
1533
1436
|
// src/sessions/git/baseline.ts
|
|
1534
|
-
import { lstat
|
|
1535
|
-
import { join as
|
|
1437
|
+
import { lstat } from "fs/promises";
|
|
1438
|
+
import { join as join3 } from "path";
|
|
1536
1439
|
var SESSION_START_REF = "session start";
|
|
1537
1440
|
var MAX_PREEXISTING_FILES = 200;
|
|
1538
1441
|
var DELETED = "deleted";
|
|
1539
1442
|
async function fingerprintFile(cwd, path) {
|
|
1540
1443
|
try {
|
|
1541
|
-
const stats = await
|
|
1444
|
+
const stats = await lstat(join3(cwd, path));
|
|
1542
1445
|
return `${stats.size}:${Math.trunc(stats.mtimeMs)}`;
|
|
1543
1446
|
} catch {
|
|
1544
1447
|
return DELETED;
|
|
@@ -1862,23 +1765,23 @@ function gitFailure(error2, path) {
|
|
|
1862
1765
|
"git isn't installed, or isn't on the PATH of the terminal running bondsmithd."
|
|
1863
1766
|
);
|
|
1864
1767
|
}
|
|
1865
|
-
const
|
|
1866
|
-
if (
|
|
1768
|
+
const text2 = typeof stderr === "string" ? stderr : "";
|
|
1769
|
+
if (text2.includes("dubious ownership")) {
|
|
1867
1770
|
return new Error(
|
|
1868
1771
|
`git doesn't trust this folder because another user owns it. If you trust it, run: git config --global --add safe.directory ${shellQuote(path)}`
|
|
1869
1772
|
);
|
|
1870
1773
|
}
|
|
1871
|
-
const fatal =
|
|
1774
|
+
const fatal = text2.split("\n").find((line) => line.startsWith("fatal:"));
|
|
1872
1775
|
return new Error(
|
|
1873
1776
|
fatal ?? (error2 instanceof Error ? error2.message : "git failed to inspect that folder.")
|
|
1874
1777
|
);
|
|
1875
1778
|
}
|
|
1876
1779
|
async function validateProjectPath(input) {
|
|
1877
1780
|
const expanded = expandHome(input.trim());
|
|
1878
|
-
if (!
|
|
1781
|
+
if (!isAbsolute(expanded)) return { valid: false, reason: "relative_path" };
|
|
1879
1782
|
const path = resolve(expanded);
|
|
1880
1783
|
try {
|
|
1881
|
-
const stats = await
|
|
1784
|
+
const stats = await stat(path);
|
|
1882
1785
|
if (!stats.isDirectory()) return { valid: false, reason: "not_a_directory" };
|
|
1883
1786
|
} catch (error2) {
|
|
1884
1787
|
const reason = invalidReasonForFsError(error2);
|
|
@@ -1905,7 +1808,7 @@ import { randomBytes, verify as verify2 } from "crypto";
|
|
|
1905
1808
|
// src/security/trusted-controllers.ts
|
|
1906
1809
|
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
1907
1810
|
import { homedir as homedir3 } from "os";
|
|
1908
|
-
import { dirname, join as
|
|
1811
|
+
import { dirname, join as join4 } from "path";
|
|
1909
1812
|
import { z as z12 } from "zod";
|
|
1910
1813
|
var trustedControllerSchema = z12.object({
|
|
1911
1814
|
id: z12.string(),
|
|
@@ -1928,7 +1831,7 @@ var trustedControllersFileSchema = z12.object({
|
|
|
1928
1831
|
controllers: z12.array(trustedControllerSchema)
|
|
1929
1832
|
});
|
|
1930
1833
|
function defaultTrustedControllersPath() {
|
|
1931
|
-
return
|
|
1834
|
+
return join4(homedir3(), ".bondsmith", "trusted-controllers.json");
|
|
1932
1835
|
}
|
|
1933
1836
|
async function loadTrustedControllers(path = defaultTrustedControllersPath()) {
|
|
1934
1837
|
let raw;
|
|
@@ -2249,12 +2152,12 @@ var ConnectionAuthState = class {
|
|
|
2249
2152
|
};
|
|
2250
2153
|
|
|
2251
2154
|
// src/sessions/agents/installed-agents.ts
|
|
2252
|
-
import { accessSync, constants
|
|
2253
|
-
import { delimiter, join as
|
|
2254
|
-
var KNOWN_AGENTS =
|
|
2155
|
+
import { accessSync, constants, statSync } from "fs";
|
|
2156
|
+
import { delimiter, join as join5 } from "path";
|
|
2157
|
+
var KNOWN_AGENTS = AGENT_IDS;
|
|
2255
2158
|
function isExecutableFile(path) {
|
|
2256
2159
|
try {
|
|
2257
|
-
accessSync(path,
|
|
2160
|
+
accessSync(path, constants.X_OK);
|
|
2258
2161
|
return statSync(path).isFile();
|
|
2259
2162
|
} catch {
|
|
2260
2163
|
return false;
|
|
@@ -2263,12 +2166,12 @@ function isExecutableFile(path) {
|
|
|
2263
2166
|
function listInstalledAgents(env = process.env) {
|
|
2264
2167
|
const directories = (env.PATH ?? "").split(delimiter).filter((directory) => directory !== "");
|
|
2265
2168
|
return KNOWN_AGENTS.filter(
|
|
2266
|
-
(agent) => directories.some((directory) => isExecutableFile(
|
|
2169
|
+
(agent) => directories.some((directory) => isExecutableFile(join5(directory, agent)))
|
|
2267
2170
|
);
|
|
2268
2171
|
}
|
|
2269
2172
|
|
|
2270
2173
|
// src/version.ts
|
|
2271
|
-
var DAEMON_VERSION = true ? "0.1.
|
|
2174
|
+
var DAEMON_VERSION = true ? "0.1.16" : "dev";
|
|
2272
2175
|
var VERSION_FLAGS = ["--version", "-v"];
|
|
2273
2176
|
function versionLine() {
|
|
2274
2177
|
return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
|
|
@@ -2277,6 +2180,241 @@ function isVersionRequest(argv) {
|
|
|
2277
2180
|
return argv.some((arg) => VERSION_FLAGS.includes(arg));
|
|
2278
2181
|
}
|
|
2279
2182
|
|
|
2183
|
+
// src/sessions/agent-reports/read-file.ts
|
|
2184
|
+
import { constants as constants3 } from "fs";
|
|
2185
|
+
import { open as open2 } from "fs/promises";
|
|
2186
|
+
|
|
2187
|
+
// src/sessions/files/read-range.ts
|
|
2188
|
+
import { constants as constants2 } from "fs";
|
|
2189
|
+
import { lstat as lstat2, open, realpath, stat as stat2 } from "fs/promises";
|
|
2190
|
+
import { isAbsolute as isAbsolute2, join as join6, sep } from "path";
|
|
2191
|
+
var MAX_RANGE_FILE_BYTES = 2 * 1024 * 1024;
|
|
2192
|
+
var REPLY_ENVELOPE_BYTES = 8 * 1024;
|
|
2193
|
+
var BINARY_SNIFF_BYTES = 8e3;
|
|
2194
|
+
function fileVersion(stats) {
|
|
2195
|
+
return `${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}:${stats.ino}`;
|
|
2196
|
+
}
|
|
2197
|
+
async function locateInside(cwd, path) {
|
|
2198
|
+
if (isAbsolute2(path)) return { refused: "not_allowed" };
|
|
2199
|
+
try {
|
|
2200
|
+
const root = await realpath(cwd);
|
|
2201
|
+
const lexical = join6(root, path);
|
|
2202
|
+
if ((await lstat2(lexical)).isSymbolicLink()) return { refused: "not_allowed" };
|
|
2203
|
+
const real = await realpath(lexical);
|
|
2204
|
+
return real === root || real.startsWith(root + sep) ? { found: real } : { refused: "missing" };
|
|
2205
|
+
} catch {
|
|
2206
|
+
return { refused: "missing" };
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
async function readExactly(handle, size) {
|
|
2210
|
+
const buffer = Buffer.allocUnsafe(size + 1);
|
|
2211
|
+
let filled = 0;
|
|
2212
|
+
while (filled < buffer.length) {
|
|
2213
|
+
const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
|
|
2214
|
+
if (bytesRead === 0) break;
|
|
2215
|
+
filled += bytesRead;
|
|
2216
|
+
}
|
|
2217
|
+
return filled === size ? buffer.subarray(0, size) : void 0;
|
|
2218
|
+
}
|
|
2219
|
+
async function currentFileVersion(cwd, path) {
|
|
2220
|
+
const located = await locateInside(cwd, path);
|
|
2221
|
+
if (!("found" in located)) return void 0;
|
|
2222
|
+
try {
|
|
2223
|
+
return fileVersion(await stat2(located.found));
|
|
2224
|
+
} catch {
|
|
2225
|
+
return void 0;
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
function splitLines(text2) {
|
|
2229
|
+
if (text2 === "") return [];
|
|
2230
|
+
const lines = text2.split("\n");
|
|
2231
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
2232
|
+
return lines.map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
2233
|
+
}
|
|
2234
|
+
async function readFileRange(cwd, path, startLine, lineCount, version) {
|
|
2235
|
+
const refuse = (reason) => ({ ok: false, path, reason });
|
|
2236
|
+
const located = await locateInside(cwd, path);
|
|
2237
|
+
if (!("found" in located)) return refuse(located.refused);
|
|
2238
|
+
let content;
|
|
2239
|
+
let handle;
|
|
2240
|
+
try {
|
|
2241
|
+
handle = await open(
|
|
2242
|
+
located.found,
|
|
2243
|
+
constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK
|
|
2244
|
+
);
|
|
2245
|
+
const before = await handle.stat();
|
|
2246
|
+
if (!before.isFile()) return refuse("not_allowed");
|
|
2247
|
+
if (fileVersion(before) !== version) return refuse("changed");
|
|
2248
|
+
if (before.size > MAX_RANGE_FILE_BYTES) return refuse("too_large");
|
|
2249
|
+
const bytes = await readExactly(handle, before.size);
|
|
2250
|
+
if (bytes === void 0 || fileVersion(await handle.stat()) !== version) {
|
|
2251
|
+
return refuse("changed");
|
|
2252
|
+
}
|
|
2253
|
+
content = bytes;
|
|
2254
|
+
} catch {
|
|
2255
|
+
return refuse("missing");
|
|
2256
|
+
} finally {
|
|
2257
|
+
await handle?.close();
|
|
2258
|
+
}
|
|
2259
|
+
if (content.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return refuse("binary");
|
|
2260
|
+
const lines = splitLines(content.toString("utf8"));
|
|
2261
|
+
const page = [];
|
|
2262
|
+
let budget = MAX_DAEMON_REPLY_BYTES - REPLY_ENVELOPE_BYTES;
|
|
2263
|
+
const wanted = Math.min(lineCount, MAX_RANGE_LINES);
|
|
2264
|
+
for (let index = startLine - 1; index < lines.length && page.length < wanted; index++) {
|
|
2265
|
+
const line = lines[index];
|
|
2266
|
+
const cost = Buffer.byteLength(JSON.stringify(line)) + 1;
|
|
2267
|
+
if (cost > budget) {
|
|
2268
|
+
if (page.length === 0) return refuse("too_large");
|
|
2269
|
+
break;
|
|
2270
|
+
}
|
|
2271
|
+
budget -= cost;
|
|
2272
|
+
page.push(line);
|
|
2273
|
+
}
|
|
2274
|
+
return { ok: true, path, startLine, lines: page, totalLines: lines.length, version };
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
// src/sessions/agent-reports/read-file.ts
|
|
2278
|
+
var ABSENT_CODES = /* @__PURE__ */ new Set(["ENOENT", "ELOOP", "ENOTDIR"]);
|
|
2279
|
+
async function readFileAt(root, relativePath, read) {
|
|
2280
|
+
const located = await locateInside(root, relativePath);
|
|
2281
|
+
if (!("found" in located)) return null;
|
|
2282
|
+
let handle;
|
|
2283
|
+
try {
|
|
2284
|
+
handle = await open2(
|
|
2285
|
+
located.found,
|
|
2286
|
+
constants3.O_RDONLY | constants3.O_NOFOLLOW | constants3.O_NONBLOCK
|
|
2287
|
+
);
|
|
2288
|
+
const info = await handle.stat();
|
|
2289
|
+
if (!info.isFile()) return null;
|
|
2290
|
+
return await read(handle, info.size);
|
|
2291
|
+
} catch (error2) {
|
|
2292
|
+
if (ABSENT_CODES.has(error2.code ?? "")) return null;
|
|
2293
|
+
throw error2;
|
|
2294
|
+
} finally {
|
|
2295
|
+
await handle?.close();
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// src/sessions/agent-reports/task-list.ts
|
|
2300
|
+
import { z as z13 } from "zod";
|
|
2301
|
+
var text = z13.string().refine((value) => value.trim() !== "");
|
|
2302
|
+
var optionalText = text.optional().catch(void 0);
|
|
2303
|
+
var taskStatus = z13.enum(TASK_STATUSES).optional().catch(void 0);
|
|
2304
|
+
var taskId = z13.union([z13.string(), z13.number()]).transform(String);
|
|
2305
|
+
var taskEventSchema = z13.discriminatedUnion("op", [
|
|
2306
|
+
z13.object({
|
|
2307
|
+
op: z13.literal("add"),
|
|
2308
|
+
id: taskId.optional().catch(void 0),
|
|
2309
|
+
subject: text,
|
|
2310
|
+
activeForm: optionalText
|
|
2311
|
+
}),
|
|
2312
|
+
z13.object({
|
|
2313
|
+
op: z13.literal("update"),
|
|
2314
|
+
id: taskId,
|
|
2315
|
+
subject: optionalText,
|
|
2316
|
+
activeForm: optionalText,
|
|
2317
|
+
status: taskStatus
|
|
2318
|
+
}),
|
|
2319
|
+
z13.object({ op: z13.literal("remove"), id: taskId }),
|
|
2320
|
+
z13.object({ op: z13.literal("replace"), tasks: z13.array(z13.unknown()) })
|
|
2321
|
+
]);
|
|
2322
|
+
var replacementTaskSchema = z13.object({
|
|
2323
|
+
subject: text,
|
|
2324
|
+
activeForm: optionalText,
|
|
2325
|
+
status: taskStatus
|
|
2326
|
+
});
|
|
2327
|
+
function applyEvent(tasks, event) {
|
|
2328
|
+
switch (event.op) {
|
|
2329
|
+
case "replace":
|
|
2330
|
+
return event.tasks.flatMap((candidate) => {
|
|
2331
|
+
const task = replacementTaskSchema.safeParse(candidate);
|
|
2332
|
+
if (!task.success) return [];
|
|
2333
|
+
const { subject, activeForm, status = "pending" } = task.data;
|
|
2334
|
+
return [{ id: null, subject, activeForm, status }];
|
|
2335
|
+
});
|
|
2336
|
+
case "add":
|
|
2337
|
+
return [
|
|
2338
|
+
...tasks,
|
|
2339
|
+
{
|
|
2340
|
+
id: event.id ?? null,
|
|
2341
|
+
subject: event.subject,
|
|
2342
|
+
activeForm: event.activeForm,
|
|
2343
|
+
status: "pending"
|
|
2344
|
+
}
|
|
2345
|
+
];
|
|
2346
|
+
case "update":
|
|
2347
|
+
return tasks.map(
|
|
2348
|
+
(task) => task.id === event.id ? {
|
|
2349
|
+
...task,
|
|
2350
|
+
subject: event.subject ?? task.subject,
|
|
2351
|
+
activeForm: event.activeForm ?? task.activeForm,
|
|
2352
|
+
status: event.status ?? task.status
|
|
2353
|
+
} : task
|
|
2354
|
+
);
|
|
2355
|
+
case "remove":
|
|
2356
|
+
return tasks.filter((task) => task.id !== event.id);
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
var cut = (value) => value.slice(0, MAX_TASK_TEXT_CHARS);
|
|
2360
|
+
function replayTasks(log) {
|
|
2361
|
+
let tasks = [];
|
|
2362
|
+
for (const line of log.split("\n")) {
|
|
2363
|
+
if (!line.trim()) continue;
|
|
2364
|
+
let raw;
|
|
2365
|
+
try {
|
|
2366
|
+
raw = JSON.parse(line);
|
|
2367
|
+
} catch {
|
|
2368
|
+
continue;
|
|
2369
|
+
}
|
|
2370
|
+
const event = taskEventSchema.safeParse(raw);
|
|
2371
|
+
if (event.success) tasks = applyEvent(tasks, event.data);
|
|
2372
|
+
}
|
|
2373
|
+
return tasks.slice(0, MAX_PLAN_TASKS).map(({ subject, activeForm, status }) => ({
|
|
2374
|
+
subject: cut(subject),
|
|
2375
|
+
...activeForm && { activeForm: cut(activeForm) },
|
|
2376
|
+
status
|
|
2377
|
+
}));
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
// src/sessions/agent-reports/readers.ts
|
|
2381
|
+
var MAX_PLAN_BYTES = MAX_PLAN_CHARS * 4;
|
|
2382
|
+
var MAX_TASK_LOG_BYTES = 1024 * 1024;
|
|
2383
|
+
async function readPlanAt(root, relativePath) {
|
|
2384
|
+
const text2 = await readFileAt(root, relativePath, async (handle) => {
|
|
2385
|
+
const buffer = Buffer.alloc(MAX_PLAN_BYTES);
|
|
2386
|
+
const { bytesRead } = await handle.read(buffer, 0, MAX_PLAN_BYTES, 0);
|
|
2387
|
+
return buffer.toString("utf8", 0, bytesRead).trim();
|
|
2388
|
+
});
|
|
2389
|
+
return text2 ? text2.slice(0, MAX_PLAN_CHARS) : null;
|
|
2390
|
+
}
|
|
2391
|
+
function readPlanFile(cwd) {
|
|
2392
|
+
return readPlanAt(cwd, PLAN_FILE_PATH);
|
|
2393
|
+
}
|
|
2394
|
+
function readHookPlan(sessionId, plansDir = HOOK_PLANS_DIR) {
|
|
2395
|
+
return readPlanAt(plansDir, `${sessionId}${PLAN_FILE_SUFFIX}`);
|
|
2396
|
+
}
|
|
2397
|
+
async function readHookTasks(sessionId, plansDir = HOOK_PLANS_DIR) {
|
|
2398
|
+
const log = await readFileAt(
|
|
2399
|
+
plansDir,
|
|
2400
|
+
`${sessionId}${TASKS_FILE_SUFFIX}`,
|
|
2401
|
+
async (handle, size) => {
|
|
2402
|
+
const length = Math.min(size, MAX_TASK_LOG_BYTES);
|
|
2403
|
+
const buffer = Buffer.alloc(length);
|
|
2404
|
+
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
|
|
2405
|
+
const text2 = buffer.toString("utf8", 0, bytesRead);
|
|
2406
|
+
return length < size ? text2.slice(text2.indexOf("\n") + 1) : text2;
|
|
2407
|
+
}
|
|
2408
|
+
);
|
|
2409
|
+
return log === null ? [] : replayTasks(log);
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// src/sessions/agent-reports/session-report.ts
|
|
2413
|
+
async function readSessionReport(session) {
|
|
2414
|
+
const plan = await readHookPlan(session.id) ?? await readPlanFile(session.cwd) ?? await getSessionPlan(session.tmuxSessionName);
|
|
2415
|
+
return { plan, tasks: await readHookTasks(session.id) };
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2280
2418
|
// src/security/verify-command.ts
|
|
2281
2419
|
import { verify as verify3 } from "crypto";
|
|
2282
2420
|
var ENVELOPE_MAX_AGE_MS = 6e4;
|
|
@@ -2413,8 +2551,8 @@ function createOutputPacer(options) {
|
|
|
2413
2551
|
|
|
2414
2552
|
// src/ws/sanitize-insert-text.ts
|
|
2415
2553
|
var DISALLOWED_CHARACTERS = /[\x00-\x08\x0b-\x1f\x7f-\x9f--]/g;
|
|
2416
|
-
function sanitizeInsertText(
|
|
2417
|
-
const withLineFeeds =
|
|
2554
|
+
function sanitizeInsertText(text2) {
|
|
2555
|
+
const withLineFeeds = text2.replace(/\r\n?/g, "\n");
|
|
2418
2556
|
const sanitized = withLineFeeds.replace(DISALLOWED_CHARACTERS, "");
|
|
2419
2557
|
return { text: sanitized, strippedCount: withLineFeeds.length - sanitized.length };
|
|
2420
2558
|
}
|
|
@@ -2445,6 +2583,7 @@ function verifyRejectionMessage(reason) {
|
|
|
2445
2583
|
var REVOKED_REJECTION_MESSAGE = "connection has not completed its handshake";
|
|
2446
2584
|
var BRACKETED_PASTE_START = "\x1B[200~";
|
|
2447
2585
|
var BRACKETED_PASTE_END = "\x1B[201~";
|
|
2586
|
+
var WHEEL_ONLY_INPUT = /^(?:\x1b\[<6[45];\d+;\d+M)+$/;
|
|
2448
2587
|
function releaseSession(conn, ptyAttachments, sessionId) {
|
|
2449
2588
|
const listener = conn.getOutputListener(sessionId);
|
|
2450
2589
|
if (listener) ptyAttachments.release(sessionId, listener);
|
|
@@ -2458,6 +2597,7 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
|
|
|
2458
2597
|
let admissionChain = Promise.resolve();
|
|
2459
2598
|
const conn = {
|
|
2460
2599
|
subscriptions: /* @__PURE__ */ new Set(),
|
|
2600
|
+
mouseModeSessions: /* @__PURE__ */ new Map(),
|
|
2461
2601
|
send,
|
|
2462
2602
|
bufferedBytes,
|
|
2463
2603
|
isClosed: () => closed,
|
|
@@ -2496,6 +2636,11 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
|
|
|
2496
2636
|
for (const sessionId of [...conn.subscriptions]) {
|
|
2497
2637
|
releaseSession(conn, ptyAttachments, sessionId);
|
|
2498
2638
|
}
|
|
2639
|
+
for (const tmuxSessionName of conn.mouseModeSessions.values()) {
|
|
2640
|
+
setMouseMode(tmuxSessionName, false).catch(() => {
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
conn.mouseModeSessions.clear();
|
|
2499
2644
|
}
|
|
2500
2645
|
return { conn, releaseAllSubscriptions };
|
|
2501
2646
|
}
|
|
@@ -2729,6 +2874,11 @@ async function dispatch(conn, message, {
|
|
|
2729
2874
|
return;
|
|
2730
2875
|
}
|
|
2731
2876
|
sessionManager.acknowledge(message.sessionId);
|
|
2877
|
+
const tmuxSessionName = conn.mouseModeSessions.get(message.sessionId);
|
|
2878
|
+
if (tmuxSessionName && !WHEEL_ONLY_INPUT.test(message.data)) {
|
|
2879
|
+
await cancelCopyMode(tmuxSessionName).catch(() => {
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2732
2882
|
ptyAttachments.write(message.sessionId, message.data);
|
|
2733
2883
|
return;
|
|
2734
2884
|
}
|
|
@@ -2736,14 +2886,14 @@ async function dispatch(conn, message, {
|
|
|
2736
2886
|
const session = getSessionOrError(sessionManager, conn, message.sessionId);
|
|
2737
2887
|
if (!session) return;
|
|
2738
2888
|
sessionManager.acknowledge(message.sessionId);
|
|
2739
|
-
const { text, strippedCount } = sanitizeInsertText(message.text);
|
|
2889
|
+
const { text: text2, strippedCount } = sanitizeInsertText(message.text);
|
|
2740
2890
|
if (strippedCount > 0) {
|
|
2741
2891
|
console.warn(`bondsmithd: stripped ${strippedCount} control characters from inserted text`);
|
|
2742
2892
|
}
|
|
2743
2893
|
try {
|
|
2744
2894
|
await sendKeysLiteral(
|
|
2745
2895
|
session.tmuxSessionName,
|
|
2746
|
-
`${BRACKETED_PASTE_START}${
|
|
2896
|
+
`${BRACKETED_PASTE_START}${text2}${BRACKETED_PASTE_END}`
|
|
2747
2897
|
);
|
|
2748
2898
|
} catch (err) {
|
|
2749
2899
|
conn.send(error(errorMessageOf(err, "failed to insert text")));
|
|
@@ -2759,6 +2909,20 @@ async function dispatch(conn, message, {
|
|
|
2759
2909
|
ptyAttachments.resize(message.sessionId, message.cols, message.rows);
|
|
2760
2910
|
return;
|
|
2761
2911
|
}
|
|
2912
|
+
case "session.setMouseMode": {
|
|
2913
|
+
const session = sessionManager.get(message.sessionId);
|
|
2914
|
+
if (!session) return;
|
|
2915
|
+
if (message.enabled) conn.mouseModeSessions.set(session.id, session.tmuxSessionName);
|
|
2916
|
+
else conn.mouseModeSessions.delete(session.id);
|
|
2917
|
+
try {
|
|
2918
|
+
await setMouseMode(session.tmuxSessionName, message.enabled);
|
|
2919
|
+
if (message.enabled && conn.isClosed()) await setMouseMode(session.tmuxSessionName, false);
|
|
2920
|
+
} catch (err) {
|
|
2921
|
+
if (message.enabled) conn.mouseModeSessions.delete(session.id);
|
|
2922
|
+
console.warn(`bondsmithd: ${errorMessageOf(err, "failed to set mouse mode")}`);
|
|
2923
|
+
}
|
|
2924
|
+
return;
|
|
2925
|
+
}
|
|
2762
2926
|
case "session.stop": {
|
|
2763
2927
|
try {
|
|
2764
2928
|
if (!await sessionManager.stop(message.sessionId)) {
|
|
@@ -2840,12 +3004,13 @@ async function dispatch(conn, message, {
|
|
|
2840
3004
|
const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
|
|
2841
3005
|
if (!session) return;
|
|
2842
3006
|
try {
|
|
2843
|
-
const plan
|
|
3007
|
+
const { plan, tasks } = await readSessionReport(session);
|
|
2844
3008
|
conn.send({
|
|
2845
3009
|
type: "workspace.plan",
|
|
2846
3010
|
sessionId: message.sessionId,
|
|
2847
3011
|
requestId: message.requestId,
|
|
2848
|
-
plan
|
|
3012
|
+
plan,
|
|
3013
|
+
tasks
|
|
2849
3014
|
});
|
|
2850
3015
|
} catch (err) {
|
|
2851
3016
|
conn.send(error(errorMessageOf(err, "failed to read the session plan"), message.requestId));
|
|
@@ -3279,9 +3444,9 @@ function isMatchingKeyPair(publicKeyPem, privateKeyPem) {
|
|
|
3279
3444
|
return false;
|
|
3280
3445
|
}
|
|
3281
3446
|
}
|
|
3282
|
-
function extractPemBlock(
|
|
3447
|
+
function extractPemBlock(text2, label) {
|
|
3283
3448
|
const pattern = new RegExp(`-----BEGIN ${label}-----[\\s\\S]*?-----END ${label}-----\\n?`);
|
|
3284
|
-
return
|
|
3449
|
+
return text2.match(pattern)?.[0] ?? null;
|
|
3285
3450
|
}
|
|
3286
3451
|
|
|
3287
3452
|
// src/security/daemon-proof.ts
|
|
@@ -3420,8 +3585,8 @@ var PendingAuthorizationStore = class {
|
|
|
3420
3585
|
requestToken: randomUUID()
|
|
3421
3586
|
};
|
|
3422
3587
|
let settleFn;
|
|
3423
|
-
const resultPromise = new Promise((
|
|
3424
|
-
settleFn =
|
|
3588
|
+
const resultPromise = new Promise((resolve3) => {
|
|
3589
|
+
settleFn = resolve3;
|
|
3425
3590
|
});
|
|
3426
3591
|
const entry = {
|
|
3427
3592
|
authorization,
|
|
@@ -3532,16 +3697,16 @@ var PendingAuthorizationStore = class {
|
|
|
3532
3697
|
};
|
|
3533
3698
|
|
|
3534
3699
|
// src/security/revocation-sync.ts
|
|
3535
|
-
import { z as
|
|
3700
|
+
import { z as z14 } from "zod";
|
|
3536
3701
|
var POLL_INTERVAL_MS = 3e4;
|
|
3537
3702
|
var POLL_JITTER_MS = 6e3;
|
|
3538
3703
|
var POLL_REQUEST_TIMEOUT_MS = 1e4;
|
|
3539
3704
|
var BACKOFF_BASE_MS = 2e3;
|
|
3540
3705
|
var BACKOFF_MAX_MS = 2e4;
|
|
3541
3706
|
var MAX_CONSECUTIVE_FAILURE_BACKOFFS = 4;
|
|
3542
|
-
var checkRevocationStatusResponseSchema =
|
|
3543
|
-
pairingRevoked:
|
|
3544
|
-
revokedControllerPublicKeys:
|
|
3707
|
+
var checkRevocationStatusResponseSchema = z14.object({
|
|
3708
|
+
pairingRevoked: z14.boolean(),
|
|
3709
|
+
revokedControllerPublicKeys: z14.array(z14.string())
|
|
3545
3710
|
});
|
|
3546
3711
|
function jitteredInterval(deps) {
|
|
3547
3712
|
const interval = deps.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
@@ -3891,6 +4056,45 @@ ${new TmuxNotFoundError().message}
|
|
|
3891
4056
|
// src/sessions/session-manager.ts
|
|
3892
4057
|
import { v4 as uuidv4 } from "uuid";
|
|
3893
4058
|
|
|
4059
|
+
// src/sessions/agent-reports/lifecycle.ts
|
|
4060
|
+
import { readdir, rm as rm4, stat as stat3 } from "fs/promises";
|
|
4061
|
+
import { basename, join as join9 } from "path";
|
|
4062
|
+
var SWEEP_MIN_AGE_MS = 6e4;
|
|
4063
|
+
async function removeHookPlanFiles(sessionId, plansDir = HOOK_PLANS_DIR) {
|
|
4064
|
+
await Promise.all(
|
|
4065
|
+
[PLAN_FILE_SUFFIX, TASKS_FILE_SUFFIX].map(
|
|
4066
|
+
(suffix) => rm4(join9(plansDir, `${basename(sessionId)}${suffix}`), { force: true })
|
|
4067
|
+
)
|
|
4068
|
+
);
|
|
4069
|
+
}
|
|
4070
|
+
function hookFileSessionId(name) {
|
|
4071
|
+
for (const suffix of [PLAN_FILE_SUFFIX, TASKS_FILE_SUFFIX]) {
|
|
4072
|
+
const sessionId = name.endsWith(suffix) ? name.slice(0, -suffix.length) : "";
|
|
4073
|
+
if (sessionId && !sessionId.includes(".")) return sessionId;
|
|
4074
|
+
}
|
|
4075
|
+
return null;
|
|
4076
|
+
}
|
|
4077
|
+
async function sweepOrphanedHookPlanFiles(liveSessionIds, plansDir = HOOK_PLANS_DIR) {
|
|
4078
|
+
let names;
|
|
4079
|
+
try {
|
|
4080
|
+
names = await readdir(plansDir);
|
|
4081
|
+
} catch (error2) {
|
|
4082
|
+
if (ABSENT_CODES.has(error2.code ?? "")) return;
|
|
4083
|
+
throw error2;
|
|
4084
|
+
}
|
|
4085
|
+
const now = Date.now();
|
|
4086
|
+
await Promise.all(
|
|
4087
|
+
names.map(async (name) => {
|
|
4088
|
+
const sessionId = hookFileSessionId(name);
|
|
4089
|
+
if (sessionId === null || liveSessionIds.has(sessionId)) return;
|
|
4090
|
+
const path = join9(plansDir, name);
|
|
4091
|
+
const modifiedMs = await stat3(path).then((info) => info.mtimeMs).catch(() => null);
|
|
4092
|
+
if (modifiedMs === null || now - modifiedMs < SWEEP_MIN_AGE_MS) return;
|
|
4093
|
+
await rm4(path, { force: true });
|
|
4094
|
+
})
|
|
4095
|
+
);
|
|
4096
|
+
}
|
|
4097
|
+
|
|
3894
4098
|
// src/sessions/git/worktree.ts
|
|
3895
4099
|
import { execFile as execFileCb3 } from "child_process";
|
|
3896
4100
|
import { promisify as promisify3 } from "util";
|
|
@@ -3989,19 +4193,18 @@ function decodeSkippedFiles(raw) {
|
|
|
3989
4193
|
|
|
3990
4194
|
// src/sessions/tmux/agent-state.ts
|
|
3991
4195
|
var AGENT_STATE_OPTION = "@bondsmith-agent-state";
|
|
3992
|
-
var
|
|
3993
|
-
var
|
|
3994
|
-
var LIVE_SESSION_REPORTS_FORMAT = `#{session_name} #{${AGENT_STATE_OPTION}} #{window_bell_flag} #{${SETUP_FAILED_OPTION}} #{${SETUP_SKIPPED_OPTION}} #{${
|
|
4196
|
+
var PULL_REQUESTS_OPTION = "@bondsmith-prs";
|
|
4197
|
+
var PULL_REQUEST_URL = /https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+/g;
|
|
4198
|
+
var LIVE_SESSION_REPORTS_FORMAT = `#{session_name} #{${AGENT_STATE_OPTION}} #{window_bell_flag} #{${SETUP_FAILED_OPTION}} #{${SETUP_SKIPPED_OPTION}} #{${PULL_REQUESTS_OPTION}}`;
|
|
3995
4199
|
var AGENT_STATE_OUTPUT_GRACE_MS = 2e3;
|
|
3996
4200
|
var FUTURE_REPORT_ALLOWANCE_MS = 2e3;
|
|
3997
4201
|
var WORKING_SILENCE_MS = 12e4;
|
|
3998
4202
|
var SETTLE_QUIET_MS = 8e3;
|
|
3999
4203
|
var ACKNOWLEDGE_GRACE_MS = 3e3;
|
|
4000
4204
|
var SETTLED_STATES = /* @__PURE__ */ new Set(["waiting", "done"]);
|
|
4001
|
-
function
|
|
4002
|
-
const
|
|
4003
|
-
|
|
4004
|
-
return line.length > MAX_HEADLINE_CHARS ? `${line.slice(0, MAX_HEADLINE_CHARS - 1)}\u2026` : line;
|
|
4205
|
+
function parsePullRequestUrls(raw) {
|
|
4206
|
+
const urls = [...new Set(raw.match(PULL_REQUEST_URL) ?? [])];
|
|
4207
|
+
return urls.slice(-MAX_SESSION_PULL_REQUESTS);
|
|
4005
4208
|
}
|
|
4006
4209
|
function isAgentState(value) {
|
|
4007
4210
|
return AGENT_STATES.includes(value);
|
|
@@ -4015,7 +4218,7 @@ function parseLiveSessionReports(output) {
|
|
|
4015
4218
|
bell = "",
|
|
4016
4219
|
setupFailed = "",
|
|
4017
4220
|
setupSkipped = "",
|
|
4018
|
-
|
|
4221
|
+
pullRequests = ""
|
|
4019
4222
|
] = line.split(" ");
|
|
4020
4223
|
if (sessionName) {
|
|
4021
4224
|
reports.set(sessionName, {
|
|
@@ -4023,17 +4226,22 @@ function parseLiveSessionReports(output) {
|
|
|
4023
4226
|
bell: bell === "1",
|
|
4024
4227
|
setupFailed: setupFailed === "1",
|
|
4025
4228
|
setupSkipped: decodeSkippedFiles(setupSkipped),
|
|
4026
|
-
|
|
4229
|
+
pullRequestUrls: parsePullRequestUrls(pullRequests)
|
|
4027
4230
|
});
|
|
4028
4231
|
}
|
|
4029
4232
|
}
|
|
4030
4233
|
return reports;
|
|
4031
4234
|
}
|
|
4032
|
-
function
|
|
4235
|
+
function parseReport(raw) {
|
|
4033
4236
|
if (!raw) return null;
|
|
4034
4237
|
const [state, epoch] = raw.split(":");
|
|
4035
4238
|
if (!state || !isAgentState(state) || !epoch || !/^\d+$/.test(epoch)) return null;
|
|
4036
|
-
|
|
4239
|
+
return { state, reportedAtMs: Number(epoch) * 1e3 };
|
|
4240
|
+
}
|
|
4241
|
+
function resolveAgentState(raw, lastActivityAt, nowMs = Date.now()) {
|
|
4242
|
+
const parsed = parseReport(raw);
|
|
4243
|
+
if (!parsed) return null;
|
|
4244
|
+
const { state, reportedAtMs } = parsed;
|
|
4037
4245
|
if (reportedAtMs > nowMs + FUTURE_REPORT_ALLOWANCE_MS) return null;
|
|
4038
4246
|
if (SETTLED_STATES.has(state) && lastActivityAt) {
|
|
4039
4247
|
if (Date.parse(lastActivityAt) > reportedAtMs + AGENT_STATE_OUTPUT_GRACE_MS) return null;
|
|
@@ -4053,79 +4261,137 @@ function hasSettled(lastActivityAt, acknowledgedAtMs, nowMs = Date.now()) {
|
|
|
4053
4261
|
}
|
|
4054
4262
|
return nowMs - lastActivityMs >= SETTLE_QUIET_MS;
|
|
4055
4263
|
}
|
|
4264
|
+
var REPORT_ROUNDING_MS = 1e3;
|
|
4265
|
+
function isDoneAcknowledged(raw, acknowledgedAtMs) {
|
|
4266
|
+
const reportedAtMs = parseReport(raw)?.reportedAtMs;
|
|
4267
|
+
if (acknowledgedAtMs === null || reportedAtMs === void 0) return false;
|
|
4268
|
+
return acknowledgedAtMs >= reportedAtMs + REPORT_ROUNDING_MS;
|
|
4269
|
+
}
|
|
4056
4270
|
function resolveSessionAttention(report, lastActivityAt, { nowMs, acknowledgedAtMs = null } = {}) {
|
|
4057
4271
|
if (report?.bell) return "waiting";
|
|
4058
4272
|
const reported = resolveAgentState(report?.agentState, lastActivityAt, nowMs);
|
|
4273
|
+
if (reported === "done" && isDoneAcknowledged(report?.agentState, acknowledgedAtMs)) return null;
|
|
4059
4274
|
if (reported) return reported;
|
|
4060
4275
|
return hasSettled(lastActivityAt, acknowledgedAtMs, nowMs) ? "waiting" : null;
|
|
4061
4276
|
}
|
|
4062
4277
|
|
|
4063
4278
|
// src/sessions/tmux/create.ts
|
|
4064
4279
|
import { access } from "fs/promises";
|
|
4065
|
-
import { homedir as homedir7 } from "os";
|
|
4066
|
-
import { join as join11 } from "path";
|
|
4067
4280
|
|
|
4068
|
-
// src/sessions/agents/
|
|
4281
|
+
// src/sessions/agents/adapter.ts
|
|
4282
|
+
var ENV_ASSIGNMENT_WORD = String.raw`\w+=\S*`;
|
|
4283
|
+
var ENV_ASSIGNMENT = new RegExp(`^${ENV_ASSIGNMENT_WORD}$`);
|
|
4284
|
+
function leadingBinary(command) {
|
|
4285
|
+
return command.split(/\s+/).filter(Boolean).find((word) => !ENV_ASSIGNMENT.test(word));
|
|
4286
|
+
}
|
|
4287
|
+
function plainAdapter(id) {
|
|
4288
|
+
return {
|
|
4289
|
+
id,
|
|
4290
|
+
matches: (command) => leadingBinary(command) === id,
|
|
4291
|
+
prepareCommand: async (command) => command
|
|
4292
|
+
};
|
|
4293
|
+
}
|
|
4294
|
+
|
|
4295
|
+
// src/sessions/agents/claude/hooks.ts
|
|
4069
4296
|
import { execFile as execFile4 } from "child_process";
|
|
4070
4297
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
4071
|
-
import { mkdir as mkdir4, rename as rename4, rm as
|
|
4298
|
+
import { mkdir as mkdir4, rename as rename4, rm as rm5, writeFile as writeFile4 } from "fs/promises";
|
|
4072
4299
|
import { homedir as homedir6 } from "os";
|
|
4073
|
-
import { join as
|
|
4300
|
+
import { join as join10 } from "path";
|
|
4074
4301
|
import { promisify as promisify4 } from "util";
|
|
4075
4302
|
|
|
4076
4303
|
// src/sessions/session-env.ts
|
|
4077
4304
|
var SESSION_ENV_VAR = "BONDSMITH_SESSION";
|
|
4078
4305
|
|
|
4079
|
-
// src/sessions/
|
|
4080
|
-
|
|
4081
|
-
var MIN_HEADLINE_CHARS = 4;
|
|
4082
|
-
function planHookScript(plansDir) {
|
|
4306
|
+
// src/sessions/agent-reports/hook-scripts.ts
|
|
4307
|
+
function storePlanScript(plansDir, planExpr) {
|
|
4083
4308
|
return [
|
|
4084
4309
|
`let d="";`,
|
|
4085
4310
|
`process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
|
|
4086
|
-
`const
|
|
4311
|
+
`const j=JSON.parse(d),p=${planExpr};if(typeof p!=="string"||!p)return;`,
|
|
4087
4312
|
`const fs=require("fs"),path=require("path"),dir=${JSON.stringify(plansDir)};`,
|
|
4088
4313
|
// basename keeps an odd session value from naming a file outside the plans folder.
|
|
4089
|
-
`const name=path.basename(process.env.${SESSION_ENV_VAR})
|
|
4314
|
+
`const name=path.basename(process.env.${SESSION_ENV_VAR})+${JSON.stringify(PLAN_FILE_SUFFIX)},tmp=path.join(dir,"."+name+"."+process.pid);`,
|
|
4090
4315
|
// Written aside and renamed: a reader never sees half a plan, and a symlink in the way is replaced, not followed.
|
|
4091
4316
|
`fs.mkdirSync(dir,{recursive:true,mode:0o700});`,
|
|
4092
4317
|
`fs.writeFileSync(tmp,p,{mode:0o600});fs.renameSync(tmp,path.join(dir,name))`,
|
|
4093
4318
|
`}catch{}})`
|
|
4094
4319
|
].join("");
|
|
4095
4320
|
}
|
|
4096
|
-
function
|
|
4321
|
+
function appendTaskEventScript(plansDir, eventExpr) {
|
|
4322
|
+
return [
|
|
4323
|
+
`let d="";`,
|
|
4324
|
+
`process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
|
|
4325
|
+
`const j=JSON.parse(d),e=${eventExpr};if(!e)return;`,
|
|
4326
|
+
`const fs=require("fs"),path=require("path"),dir=${JSON.stringify(plansDir)},c=fs.constants;`,
|
|
4327
|
+
`fs.mkdirSync(dir,{recursive:true,mode:0o700});`,
|
|
4328
|
+
// O_NOFOLLOW so a symlink in the way is refused rather than written through.
|
|
4329
|
+
`const fd=fs.openSync(path.join(dir,path.basename(process.env.${SESSION_ENV_VAR})+${JSON.stringify(TASKS_FILE_SUFFIX)}),c.O_WRONLY|c.O_APPEND|c.O_CREAT|c.O_NOFOLLOW,0o600);`,
|
|
4330
|
+
`try{fs.writeSync(fd,JSON.stringify(e)+"\\n")}finally{fs.closeSync(fd)}`,
|
|
4331
|
+
`}catch{}})`
|
|
4332
|
+
].join("");
|
|
4333
|
+
}
|
|
4334
|
+
var PR_CREATE_MENTION = "gh pr create";
|
|
4335
|
+
var PR_CREATE_COMMAND = /(?:^|[;&|\n(])\s*(?:\w+=\S*\s+)*gh\s+pr\s+create\b/;
|
|
4336
|
+
function recordPullRequestScript(commandExpr, outputExpr) {
|
|
4097
4337
|
return [
|
|
4098
4338
|
`let d="";`,
|
|
4099
4339
|
`process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
|
|
4100
|
-
`const
|
|
4101
|
-
`
|
|
4102
|
-
`
|
|
4103
|
-
`const
|
|
4104
|
-
|
|
4105
|
-
`
|
|
4340
|
+
`const j=JSON.parse(d),c=${commandExpr},o=${outputExpr};`,
|
|
4341
|
+
`if(typeof c!=="string"||typeof o!=="string"||!${PR_CREATE_COMMAND.toString()}.test(c))return;`,
|
|
4342
|
+
`const m=o.match(${PULL_REQUEST_URL.toString()});if(!m)return;const u=m[m.length-1];`,
|
|
4343
|
+
`const cp=require("child_process"),p=process.env.TMUX_PANE;`,
|
|
4344
|
+
`const cur=cp.execFileSync("tmux",["show-options","-qv","-t",p,"${PULL_REQUESTS_OPTION}"],{encoding:"utf8"}).split(/\\s+/).filter(Boolean);`,
|
|
4345
|
+
`if(cur.includes(u))return;`,
|
|
4346
|
+
`cp.execFileSync("tmux",["set-option","-t",p,"-q","${PULL_REQUESTS_OPTION}",cur.concat(u).slice(-${MAX_SESSION_PULL_REQUESTS}).join(" ")])`,
|
|
4106
4347
|
`}catch{}})`
|
|
4107
4348
|
].join("");
|
|
4108
4349
|
}
|
|
4109
4350
|
|
|
4110
|
-
// src/sessions/
|
|
4351
|
+
// src/sessions/agent-reports/hook-commands.ts
|
|
4111
4352
|
function shellQuote3(value) {
|
|
4112
4353
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
4113
4354
|
}
|
|
4114
4355
|
function nodeHookCommand(script, nodePath) {
|
|
4115
4356
|
return `[ -n "$${SESSION_ENV_VAR}" ] && ${shellQuote3(nodePath)} -e ${shellQuote3(script)}; exit 0`;
|
|
4116
4357
|
}
|
|
4358
|
+
function recordPullRequestCommand(script, nodePath) {
|
|
4359
|
+
const run = `${shellQuote3(nodePath)} -e ${shellQuote3(script)}`;
|
|
4360
|
+
return `[ -n "$${SESSION_ENV_VAR}" ] && { input=$(cat); case "$input" in *"${PR_CREATE_MENTION}"*) printf '%s' "$input" | ${run};; esac; }; exit 0`;
|
|
4361
|
+
}
|
|
4362
|
+
function stateHookCommand(state) {
|
|
4363
|
+
return `[ -n "$${SESSION_ENV_VAR}" ] && tmux set-option -t "$TMUX_PANE" -q ${AGENT_STATE_OPTION} "${state}:$(date +%s)"; exit 0`;
|
|
4364
|
+
}
|
|
4365
|
+
|
|
4366
|
+
// src/sessions/agents/claude/scripts.ts
|
|
4367
|
+
var PLAN = `j.tool_response?.plan??j.tool_input?.plan`;
|
|
4368
|
+
var TASK_EVENT_BY_TOOL = {
|
|
4369
|
+
TaskCreate: `{op:"add",id:j.tool_response?.task?.id,subject:i.subject,activeForm:i.activeForm}`,
|
|
4370
|
+
TaskUpdate: `i.status==="deleted"?{op:"remove",id:i.taskId}:{op:"update",id:i.taskId,subject:i.subject,status:i.status,activeForm:i.activeForm}`,
|
|
4371
|
+
TodoWrite: `Array.isArray(i.todos)?{op:"replace",tasks:i.todos.map(t=>({subject:t?.content,status:t?.status,activeForm:t?.activeForm}))}:undefined`
|
|
4372
|
+
};
|
|
4373
|
+
var TASK_TOOLS = Object.keys(TASK_EVENT_BY_TOOL);
|
|
4374
|
+
var TASK_EVENT = `(()=>{const i=j.tool_input||{};switch(j.tool_name){${Object.entries(
|
|
4375
|
+
TASK_EVENT_BY_TOOL
|
|
4376
|
+
).map(([tool, event]) => `case${JSON.stringify(tool)}:return ${event};`).join("")}}})()`;
|
|
4377
|
+
var planHookScript = (plansDir) => storePlanScript(plansDir, PLAN);
|
|
4378
|
+
var taskHookScript = (plansDir) => appendTaskEventScript(plansDir, TASK_EVENT);
|
|
4379
|
+
var pullRequestHookScript = () => recordPullRequestScript(`j.tool_input?.command`, `j.tool_response?.stdout`);
|
|
4380
|
+
|
|
4381
|
+
// src/sessions/agents/claude/settings.ts
|
|
4117
4382
|
function planHookCommand(nodePath = process.execPath, plansDir = HOOK_PLANS_DIR) {
|
|
4118
4383
|
return nodeHookCommand(planHookScript(plansDir), nodePath);
|
|
4119
4384
|
}
|
|
4120
|
-
function
|
|
4121
|
-
return nodeHookCommand(
|
|
4385
|
+
function taskHookCommand(nodePath = process.execPath, plansDir = HOOK_PLANS_DIR) {
|
|
4386
|
+
return nodeHookCommand(taskHookScript(plansDir), nodePath);
|
|
4122
4387
|
}
|
|
4123
|
-
function
|
|
4124
|
-
return
|
|
4388
|
+
function pullRequestHookCommand(nodePath = process.execPath) {
|
|
4389
|
+
return recordPullRequestCommand(pullRequestHookScript(), nodePath);
|
|
4125
4390
|
}
|
|
4126
4391
|
var STATE_HOOK_TIMEOUT_SECONDS = 5;
|
|
4127
4392
|
var PLAN_HOOK_TIMEOUT_SECONDS = 10;
|
|
4128
|
-
var
|
|
4393
|
+
var TASK_HOOK_TIMEOUT_SECONDS = 10;
|
|
4394
|
+
var PULL_REQUEST_HOOK_TIMEOUT_SECONDS = 5;
|
|
4129
4395
|
function claudeSettings() {
|
|
4130
4396
|
const run = (command, timeout) => ({ type: "command", command, timeout });
|
|
4131
4397
|
const report = (state) => run(stateHookCommand(state), STATE_HOOK_TIMEOUT_SECONDS);
|
|
@@ -4135,35 +4401,44 @@ function claudeSettings() {
|
|
|
4135
4401
|
{
|
|
4136
4402
|
matcher: "ExitPlanMode",
|
|
4137
4403
|
hooks: [run(planHookCommand(), PLAN_HOOK_TIMEOUT_SECONDS)]
|
|
4138
|
-
}
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4404
|
+
},
|
|
4405
|
+
{
|
|
4406
|
+
matcher: TASK_TOOLS.join("|"),
|
|
4407
|
+
hooks: [run(taskHookCommand(), TASK_HOOK_TIMEOUT_SECONDS)]
|
|
4408
|
+
},
|
|
4142
4409
|
{
|
|
4143
|
-
|
|
4410
|
+
matcher: "Bash",
|
|
4411
|
+
hooks: [run(pullRequestHookCommand(), PULL_REQUEST_HOOK_TIMEOUT_SECONDS)]
|
|
4144
4412
|
}
|
|
4145
4413
|
],
|
|
4146
|
-
|
|
4147
|
-
|
|
4414
|
+
// Working from the moment a prompt is sent; waiting when it needs the user; done when a turn ends.
|
|
4415
|
+
UserPromptSubmit: [{ hooks: [report("working")] }],
|
|
4416
|
+
// The plan is stored while it waits for approval, so it shows before the user decides.
|
|
4417
|
+
PermissionRequest: [
|
|
4418
|
+
{ matcher: "*", hooks: [report("waiting")] },
|
|
4419
|
+
{ matcher: "ExitPlanMode", hooks: [run(planHookCommand(), PLAN_HOOK_TIMEOUT_SECONDS)] }
|
|
4420
|
+
],
|
|
4421
|
+
Stop: [{ hooks: [report("done")] }],
|
|
4148
4422
|
StopFailure: [{ hooks: [report("waiting")] }]
|
|
4149
4423
|
}
|
|
4150
4424
|
};
|
|
4151
4425
|
}
|
|
4152
4426
|
|
|
4153
|
-
// src/sessions/agents/claude
|
|
4427
|
+
// src/sessions/agents/claude/hooks.ts
|
|
4154
4428
|
var execFileAsync = promisify4(execFile4);
|
|
4155
|
-
var CLAUDE_HOOKS_DIR =
|
|
4429
|
+
var CLAUDE_HOOKS_DIR = join10(homedir6(), ".bondsmith", "hooks");
|
|
4156
4430
|
var AGENT_HOOKS_ENV_VAR = "BONDSMITH_AGENT_HOOKS";
|
|
4157
4431
|
var HOOKS_OFF_VALUES = /^(off|0|false|no)$/i;
|
|
4158
4432
|
var CLAUDE_BINARY = "claude";
|
|
4159
|
-
var LEADING_CLAUDE = new RegExp(
|
|
4433
|
+
var LEADING_CLAUDE = new RegExp(
|
|
4434
|
+
`^(\\s*(?:${ENV_ASSIGNMENT_WORD}\\s+)*)${CLAUDE_BINARY}(?=\\s|$)`
|
|
4435
|
+
);
|
|
4160
4436
|
var SETTINGS_FLAG = "--settings";
|
|
4161
4437
|
var SUPPORT_PROBE_TIMEOUT_MS = 1e4;
|
|
4162
4438
|
var SUPPORT_RETRY_AFTER_MS = 6e4;
|
|
4163
4439
|
function isPlainClaudeCommand(command) {
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
return !words.some((word) => word === SETTINGS_FLAG || word.startsWith(`${SETTINGS_FLAG}=`));
|
|
4440
|
+
if (leadingBinary(command) !== CLAUDE_BINARY) return false;
|
|
4441
|
+
return !command.trim().split(/\s+/).some((word) => word === SETTINGS_FLAG || word.startsWith(`${SETTINGS_FLAG}=`));
|
|
4167
4442
|
}
|
|
4168
4443
|
async function probeSettingsSupport() {
|
|
4169
4444
|
try {
|
|
@@ -4204,14 +4479,14 @@ async function prepareAgentCommand(command, {
|
|
|
4204
4479
|
if (HOOKS_OFF_VALUES.test(env[AGENT_HOOKS_ENV_VAR] ?? "")) return command;
|
|
4205
4480
|
if (!isPlainClaudeCommand(command)) return command;
|
|
4206
4481
|
if (!await supportsSettings()) return command;
|
|
4207
|
-
const file =
|
|
4482
|
+
const file = join10(hooksDir, "claude-settings.json");
|
|
4208
4483
|
const staging = `${file}.${randomUUID2()}.tmp`;
|
|
4209
4484
|
try {
|
|
4210
4485
|
await mkdir4(hooksDir, { recursive: true });
|
|
4211
4486
|
await writeFile4(staging, JSON.stringify(claudeSettings(), null, " "));
|
|
4212
4487
|
await rename4(staging, file);
|
|
4213
4488
|
} catch (error2) {
|
|
4214
|
-
await
|
|
4489
|
+
await rm5(staging, { force: true }).catch(() => void 0);
|
|
4215
4490
|
console.error(
|
|
4216
4491
|
"could not write the Claude Code hook settings; starting claude without them",
|
|
4217
4492
|
error2
|
|
@@ -4224,6 +4499,29 @@ async function prepareAgentCommand(command, {
|
|
|
4224
4499
|
);
|
|
4225
4500
|
}
|
|
4226
4501
|
|
|
4502
|
+
// src/sessions/agents/claude/adapter.ts
|
|
4503
|
+
var claudeAdapter = {
|
|
4504
|
+
id: "claude",
|
|
4505
|
+
matches: (command) => leadingBinary(command) === "claude",
|
|
4506
|
+
prepareCommand: (command) => prepareAgentCommand(command)
|
|
4507
|
+
};
|
|
4508
|
+
|
|
4509
|
+
// src/sessions/agents/registry.ts
|
|
4510
|
+
var ADAPTERS = {
|
|
4511
|
+
claude: claudeAdapter,
|
|
4512
|
+
codex: plainAdapter("codex"),
|
|
4513
|
+
opencode: plainAdapter("opencode")
|
|
4514
|
+
};
|
|
4515
|
+
function adapterFor(command) {
|
|
4516
|
+
return Object.values(ADAPTERS).find((adapter) => adapter.matches(command));
|
|
4517
|
+
}
|
|
4518
|
+
function agentIdFor(command) {
|
|
4519
|
+
return adapterFor(command)?.id ?? null;
|
|
4520
|
+
}
|
|
4521
|
+
async function prepareCommand(command) {
|
|
4522
|
+
return await adapterFor(command)?.prepareCommand(command) ?? command;
|
|
4523
|
+
}
|
|
4524
|
+
|
|
4227
4525
|
// src/sessions/git/start-point.ts
|
|
4228
4526
|
var WorktreeBaseBranchNotFoundError = class extends Error {
|
|
4229
4527
|
constructor(branch) {
|
|
@@ -4247,15 +4545,26 @@ async function resolveWorktreeStartPoint(sourceCwd, baseBranch) {
|
|
|
4247
4545
|
throw new WorktreeBaseBranchNotFoundError(baseBranch);
|
|
4248
4546
|
}
|
|
4249
4547
|
|
|
4548
|
+
// src/sessions/worktree-root.ts
|
|
4549
|
+
import { lstat as lstat3, realpath as realpath2 } from "fs/promises";
|
|
4550
|
+
import { homedir as homedir7 } from "os";
|
|
4551
|
+
import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute3, join as join11, resolve as resolve2 } from "path";
|
|
4552
|
+
function worktreeRoot() {
|
|
4553
|
+
return join11(homedir7(), ".bondsmith", "worktrees");
|
|
4554
|
+
}
|
|
4555
|
+
function worktreePathFor(sessionId) {
|
|
4556
|
+
return join11(worktreeRoot(), sessionId);
|
|
4557
|
+
}
|
|
4558
|
+
|
|
4250
4559
|
// src/sessions/worktree-setup/copy-files.ts
|
|
4251
4560
|
import { constants as constants4 } from "fs";
|
|
4252
|
-
import { copyFile, glob, lstat as
|
|
4253
|
-
import { dirname as
|
|
4561
|
+
import { copyFile, glob, lstat as lstat4, mkdir as mkdir5, realpath as realpath3 } from "fs/promises";
|
|
4562
|
+
import { dirname as dirname5, isAbsolute as isAbsolute4, join as join12, relative, sep as sep2 } from "path";
|
|
4254
4563
|
var GIT_EXIT_NONE_IGNORED = 1;
|
|
4255
4564
|
var TRACKED_CHECK_BATCH = 200;
|
|
4256
4565
|
function isInside(root, path) {
|
|
4257
4566
|
const rel = relative(root, path);
|
|
4258
|
-
return rel !== ".." && !rel.startsWith(`..${sep2}`) && !
|
|
4567
|
+
return rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute4(rel);
|
|
4259
4568
|
}
|
|
4260
4569
|
async function ignoredPaths(worktree, paths) {
|
|
4261
4570
|
if (paths.length === 0) return /* @__PURE__ */ new Set();
|
|
@@ -4284,13 +4593,13 @@ async function trackedPaths(worktree, paths) {
|
|
|
4284
4593
|
}
|
|
4285
4594
|
return tracked;
|
|
4286
4595
|
}
|
|
4287
|
-
async function copyFilesIntoWorktree(sourceRoot,
|
|
4596
|
+
async function copyFilesIntoWorktree(sourceRoot, worktreeRoot2, globs) {
|
|
4288
4597
|
const result = { copied: [], failed: 0, skipped: [] };
|
|
4289
4598
|
const patterns = globs.filter(isSafeCopyGlob);
|
|
4290
4599
|
if (patterns.length === 0) return result;
|
|
4291
4600
|
try {
|
|
4292
|
-
const source = await
|
|
4293
|
-
const worktree = await
|
|
4601
|
+
const source = await realpath3(sourceRoot);
|
|
4602
|
+
const worktree = await realpath3(worktreeRoot2);
|
|
4294
4603
|
const candidates = [];
|
|
4295
4604
|
for await (const match of glob(patterns, { cwd: source })) {
|
|
4296
4605
|
if (match.split(sep2).includes(".git")) continue;
|
|
@@ -4333,11 +4642,11 @@ async function copyFilesIntoWorktree(sourceRoot, worktreeRoot, globs) {
|
|
|
4333
4642
|
return result;
|
|
4334
4643
|
}
|
|
4335
4644
|
async function isCopyCandidate(source, worktree, match) {
|
|
4336
|
-
const from =
|
|
4337
|
-
if (!(await
|
|
4338
|
-
if (!isInside(source, await
|
|
4645
|
+
const from = join12(source, match);
|
|
4646
|
+
if (!(await lstat4(from)).isFile()) return false;
|
|
4647
|
+
if (!isInside(source, await realpath3(from))) return false;
|
|
4339
4648
|
try {
|
|
4340
|
-
await
|
|
4649
|
+
await lstat4(join12(worktree, match));
|
|
4341
4650
|
return false;
|
|
4342
4651
|
} catch (error2) {
|
|
4343
4652
|
if (error2.code === "ENOENT") return true;
|
|
@@ -4345,11 +4654,11 @@ async function isCopyCandidate(source, worktree, match) {
|
|
|
4345
4654
|
}
|
|
4346
4655
|
}
|
|
4347
4656
|
async function copyOne(source, worktree, match) {
|
|
4348
|
-
const to =
|
|
4349
|
-
await mkdir5(
|
|
4350
|
-
if (!isInside(worktree, await
|
|
4657
|
+
const to = join12(worktree, match);
|
|
4658
|
+
await mkdir5(dirname5(to), { recursive: true });
|
|
4659
|
+
if (!isInside(worktree, await realpath3(dirname5(to)))) return false;
|
|
4351
4660
|
try {
|
|
4352
|
-
await copyFile(
|
|
4661
|
+
await copyFile(join12(source, match), to, constants4.COPYFILE_EXCL);
|
|
4353
4662
|
} catch (error2) {
|
|
4354
4663
|
if (error2.code === "EEXIST") return false;
|
|
4355
4664
|
throw error2;
|
|
@@ -4364,9 +4673,6 @@ var InvalidCwdError = class extends Error {
|
|
|
4364
4673
|
this.name = "InvalidCwdError";
|
|
4365
4674
|
}
|
|
4366
4675
|
};
|
|
4367
|
-
function worktreePathFor(sessionId) {
|
|
4368
|
-
return join11(homedir7(), ".bondsmith", "worktrees", sessionId);
|
|
4369
|
-
}
|
|
4370
4676
|
async function createTmuxSession(input) {
|
|
4371
4677
|
const sourceCwd = expandHome(input.cwd);
|
|
4372
4678
|
let cwd = sourceCwd;
|
|
@@ -4397,7 +4703,7 @@ async function createTmuxSession(input) {
|
|
|
4397
4703
|
const tmuxSessionName = tmuxSessionNameFor(input.id);
|
|
4398
4704
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4399
4705
|
const baseline = await captureBaseline(cwd, { freshCheckout: worktreeOf !== null });
|
|
4400
|
-
const agentCommand = await
|
|
4706
|
+
const agentCommand = await prepareCommand(input.command);
|
|
4401
4707
|
const setupCommand = worktreeOf ? input.worktreeSetup?.setupCommand?.trim() : void 0;
|
|
4402
4708
|
const launchCommand = setupCommand ? wrapCommandWithSetup({ setupCommand, command: agentCommand, tmuxSessionName }) : agentCommand;
|
|
4403
4709
|
await runTmux(
|
|
@@ -4446,6 +4752,7 @@ async function createTmuxSession(input) {
|
|
|
4446
4752
|
id: input.id,
|
|
4447
4753
|
title: input.title,
|
|
4448
4754
|
command: input.command,
|
|
4755
|
+
agent: agentIdFor(input.command),
|
|
4449
4756
|
cwd,
|
|
4450
4757
|
tmuxSessionName,
|
|
4451
4758
|
status: "running",
|
|
@@ -4482,6 +4789,7 @@ function parseTmuxSessions(output) {
|
|
|
4482
4789
|
id,
|
|
4483
4790
|
title,
|
|
4484
4791
|
command,
|
|
4792
|
+
agent: agentIdFor(command),
|
|
4485
4793
|
cwd,
|
|
4486
4794
|
tmuxSessionName: sessionName,
|
|
4487
4795
|
status: "running",
|
|
@@ -4565,6 +4873,9 @@ var SessionManager = class {
|
|
|
4565
4873
|
for (const [id, baseline] of discovered.baselines) {
|
|
4566
4874
|
this.baselines.set(id, baseline);
|
|
4567
4875
|
}
|
|
4876
|
+
sweepOrphanedHookPlanFiles(new Set(this.sessions.keys())).catch(
|
|
4877
|
+
(error2) => console.error("Failed to sweep stored plans of ended sessions", error2)
|
|
4878
|
+
);
|
|
4568
4879
|
}
|
|
4569
4880
|
list() {
|
|
4570
4881
|
return Array.from(this.sessions.values());
|
|
@@ -4660,7 +4971,7 @@ ${error2.message}
|
|
|
4660
4971
|
agentState: resolveSessionAttention(report, enriched.lastActivityAt, {
|
|
4661
4972
|
acknowledgedAtMs: this.acknowledgements.get(session.id) ?? null
|
|
4662
4973
|
}),
|
|
4663
|
-
|
|
4974
|
+
...report?.pullRequestUrls?.length && { pullRequestUrls: report.pullRequestUrls },
|
|
4664
4975
|
...report?.setupFailed && { worktreeSetupFailed: true },
|
|
4665
4976
|
...report?.setupSkipped?.length && { worktreeSetupSkipped: report.setupSkipped }
|
|
4666
4977
|
};
|
|
@@ -4682,12 +4993,15 @@ ${error2.message}
|
|
|
4682
4993
|
this.sessions.delete(session.id);
|
|
4683
4994
|
this.baselines.delete(session.id);
|
|
4684
4995
|
this.acknowledgements.delete(session.id);
|
|
4996
|
+
removeHookPlanFiles(session.id).catch(
|
|
4997
|
+
(error2) => console.error("Failed to remove the session's stored plan", { sessionId: session.id, error: error2 })
|
|
4998
|
+
);
|
|
4685
4999
|
this.emitUpdated(updated);
|
|
4686
5000
|
return updated;
|
|
4687
5001
|
}
|
|
4688
5002
|
};
|
|
4689
5003
|
function snapshotEquals(a, b) {
|
|
4690
|
-
return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt && (a.agentState ?? null) === (b.agentState ?? null) && (a.
|
|
5004
|
+
return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt && (a.agentState ?? null) === (b.agentState ?? null) && JSON.stringify(a.pullRequestUrls ?? []) === JSON.stringify(b.pullRequestUrls ?? []) && (a.worktreeSetupFailed ?? false) === (b.worktreeSetupFailed ?? false) && JSON.stringify(a.worktreeSetupSkipped ?? []) === JSON.stringify(b.worktreeSetupSkipped ?? []);
|
|
4691
5005
|
}
|
|
4692
5006
|
|
|
4693
5007
|
// src/ws/server.ts
|
|
@@ -4854,10 +5168,10 @@ if (isVersionRequest(process.argv.slice(2))) {
|
|
|
4854
5168
|
process.exit(0);
|
|
4855
5169
|
}
|
|
4856
5170
|
function loadRepoRootEnv() {
|
|
4857
|
-
const repoRoot =
|
|
4858
|
-
if (!existsSync(
|
|
4859
|
-
const envLocal =
|
|
4860
|
-
const envDefault =
|
|
5171
|
+
const repoRoot = join13(dirname6(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
5172
|
+
if (!existsSync(join13(repoRoot, "pnpm-workspace.yaml"))) return;
|
|
5173
|
+
const envLocal = join13(repoRoot, ".env.local");
|
|
5174
|
+
const envDefault = join13(repoRoot, ".env");
|
|
4861
5175
|
loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
|
|
4862
5176
|
}
|
|
4863
5177
|
loadRepoRootEnv();
|