@bondsmith/bondsmithd 0.1.15 → 0.1.17
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 +34 -11
- package/dist/index.js +839 -301
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
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;
|
|
14
16
|
var MAX_SESSION_PULL_REQUESTS = 20;
|
|
15
17
|
|
|
@@ -18,7 +20,7 @@ var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
|
|
|
18
20
|
var INVALID_MESSAGE_ERROR = "invalid message";
|
|
19
21
|
|
|
20
22
|
// ../../packages/local-agent-protocol/src/protocol-version.ts
|
|
21
|
-
var PROTOCOL_VERSION =
|
|
23
|
+
var PROTOCOL_VERSION = 21;
|
|
22
24
|
|
|
23
25
|
// ../../packages/local-agent-protocol/src/branch-name.ts
|
|
24
26
|
var MAX_BRANCH_NAME_LENGTH = 250;
|
|
@@ -46,6 +48,19 @@ var PROJECT_PATH_INVALID_REASONS = [
|
|
|
46
48
|
"permission_denied"
|
|
47
49
|
];
|
|
48
50
|
|
|
51
|
+
// ../../packages/local-agent-protocol/src/project-worktree.ts
|
|
52
|
+
var WORKTREE_REMOVE_REFUSALS = [
|
|
53
|
+
"not_found",
|
|
54
|
+
"locked",
|
|
55
|
+
"session_active",
|
|
56
|
+
"has_changes"
|
|
57
|
+
];
|
|
58
|
+
var WORKTREE_BRANCH_OUTCOMES = ["deleted", "kept", "not_merged", "failed"];
|
|
59
|
+
var MAX_PROJECT_WORKTREES = 100;
|
|
60
|
+
var MAX_WORKTREE_PATH_LENGTH = 1024;
|
|
61
|
+
var MAX_WORKTREE_BRANCH_LENGTH = 255;
|
|
62
|
+
var MAX_WORKTREE_HEAD_LENGTH = 64;
|
|
63
|
+
|
|
49
64
|
// ../../packages/local-agent-protocol/src/schemas.ts
|
|
50
65
|
import { z as z3 } from "zod";
|
|
51
66
|
|
|
@@ -185,6 +200,8 @@ var MAX_WIRE_MESSAGE_BYTES = 256 * 1024;
|
|
|
185
200
|
var MAX_DAEMON_REPLY_BYTES = 200 * 1024;
|
|
186
201
|
var MAX_RANGE_LINES = 500;
|
|
187
202
|
var MAX_PLAN_CHARS = 32 * 1024;
|
|
203
|
+
var MAX_PLAN_TASKS = 50;
|
|
204
|
+
var MAX_TASK_TEXT_CHARS = 120;
|
|
188
205
|
var MAX_TERMINAL_DIMENSION = 1e3;
|
|
189
206
|
var terminalSizeSchema = z3.object({
|
|
190
207
|
cols: z3.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
|
|
@@ -205,9 +222,9 @@ var localSessionSchema = z3.object({
|
|
|
205
222
|
worktreeOf: z3.string().nullable(),
|
|
206
223
|
// .catch(undefined): a state this version doesn't know must never drop the whole session message.
|
|
207
224
|
agentState: z3.enum(AGENT_STATES).nullable().optional().catch(void 0),
|
|
225
|
+
agent: z3.enum(AGENT_IDS).nullable().optional().catch(void 0),
|
|
208
226
|
worktreeSetupFailed: z3.boolean().optional().catch(void 0),
|
|
209
227
|
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
228
|
pullRequestUrls: z3.array(z3.string().max(300)).max(MAX_SESSION_PULL_REQUESTS).optional().catch(void 0)
|
|
212
229
|
});
|
|
213
230
|
var workspaceFileEntrySchema = z3.object({
|
|
@@ -260,6 +277,36 @@ var projectPathValidationSchema = z3.discriminatedUnion("valid", [
|
|
|
260
277
|
z3.object({ valid: z3.literal(false), reason: z3.enum(PROJECT_PATH_INVALID_REASONS) })
|
|
261
278
|
]);
|
|
262
279
|
var noNul = (value) => !value.includes("\0");
|
|
280
|
+
var projectWorktreeSchema = z3.object({
|
|
281
|
+
path: z3.string().max(MAX_WORKTREE_PATH_LENGTH),
|
|
282
|
+
branch: z3.string().max(MAX_WORKTREE_BRANCH_LENGTH).nullable(),
|
|
283
|
+
head: z3.string().max(MAX_WORKTREE_HEAD_LENGTH),
|
|
284
|
+
prunable: z3.boolean(),
|
|
285
|
+
locked: z3.boolean(),
|
|
286
|
+
dirty: z3.boolean(),
|
|
287
|
+
sessionId: z3.string().nullable()
|
|
288
|
+
});
|
|
289
|
+
var worktreeRemoveResultSchema = z3.discriminatedUnion("removed", [
|
|
290
|
+
z3.object({ removed: z3.literal(true), branch: z3.enum(WORKTREE_BRANCH_OUTCOMES) }),
|
|
291
|
+
z3.object({ removed: z3.literal(false), reason: z3.enum(WORKTREE_REMOVE_REFUSALS) })
|
|
292
|
+
]);
|
|
293
|
+
var WORKTREE_LIST_REPLY_OVERHEAD_BYTES = 4096;
|
|
294
|
+
var utf8Bytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
|
|
295
|
+
function fitsWorktreeLimits(row) {
|
|
296
|
+
return row.path.length <= MAX_WORKTREE_PATH_LENGTH && (row.branch === null || row.branch.length <= MAX_WORKTREE_BRANCH_LENGTH) && row.head.length <= MAX_WORKTREE_HEAD_LENGTH;
|
|
297
|
+
}
|
|
298
|
+
function fitWorktreeReply(worktrees) {
|
|
299
|
+
const kept = [];
|
|
300
|
+
let bytes = WORKTREE_LIST_REPLY_OVERHEAD_BYTES;
|
|
301
|
+
for (const row of worktrees) {
|
|
302
|
+
if (!fitsWorktreeLimits(row)) continue;
|
|
303
|
+
const rowBytes = utf8Bytes(row) + 1;
|
|
304
|
+
if (kept.length >= MAX_PROJECT_WORKTREES || bytes + rowBytes > MAX_DAEMON_REPLY_BYTES) break;
|
|
305
|
+
kept.push(row);
|
|
306
|
+
bytes += rowBytes;
|
|
307
|
+
}
|
|
308
|
+
return { worktrees: kept, truncated: kept.length < worktrees.length };
|
|
309
|
+
}
|
|
263
310
|
var worktreeSetupSchema = z3.object({
|
|
264
311
|
copyGlobs: z3.array(z3.string().min(1).max(MAX_COPY_GLOB_LENGTH).refine(noNul).refine(isSafeCopyGlob)).max(MAX_COPY_GLOBS),
|
|
265
312
|
setupCommand: z3.string().max(MAX_SETUP_COMMAND_LENGTH).refine(noNul).optional()
|
|
@@ -356,6 +403,19 @@ var browserMessageSchema = z3.discriminatedUnion("type", [
|
|
|
356
403
|
path: z3.string().min(1).max(4096),
|
|
357
404
|
requestId: z3.string().min(1).max(200)
|
|
358
405
|
}),
|
|
406
|
+
z3.object({
|
|
407
|
+
type: z3.literal("worktree.list"),
|
|
408
|
+
repoPath: z3.string().min(1).max(4096).refine(noNul),
|
|
409
|
+
requestId: z3.string().min(1).max(200)
|
|
410
|
+
}),
|
|
411
|
+
z3.object({
|
|
412
|
+
type: z3.literal("worktree.remove"),
|
|
413
|
+
repoPath: z3.string().min(1).max(4096).refine(noNul),
|
|
414
|
+
worktreePath: z3.string().min(1).max(4096).refine(noNul),
|
|
415
|
+
force: z3.boolean(),
|
|
416
|
+
deleteBranch: z3.boolean(),
|
|
417
|
+
requestId: z3.string().min(1).max(200)
|
|
418
|
+
}),
|
|
359
419
|
z3.object({ type: z3.literal("daemon.getInfo") }),
|
|
360
420
|
// Second-controller enrollment (ADR-011/ADR-012, BD-433) — deliberately
|
|
361
421
|
// unsigned: the requesting browser has no trusted key yet to sign with.
|
|
@@ -388,6 +448,11 @@ var connectionAuthenticationFailureReasonSchema = z3.enum([
|
|
|
388
448
|
"key_agreement_failed"
|
|
389
449
|
]);
|
|
390
450
|
var connectionRejectionReasonSchema = z3.enum(["origin_not_allowed", "token_invalid"]);
|
|
451
|
+
var sessionTaskSchema = z3.object({
|
|
452
|
+
subject: z3.string().max(MAX_TASK_TEXT_CHARS),
|
|
453
|
+
activeForm: z3.string().max(MAX_TASK_TEXT_CHARS).optional(),
|
|
454
|
+
status: z3.enum(TASK_STATUSES)
|
|
455
|
+
});
|
|
391
456
|
var daemonMessageSchema = z3.discriminatedUnion("type", [
|
|
392
457
|
z3.object({ type: z3.literal("session.list"), sessions: z3.array(localSessionSchema) }),
|
|
393
458
|
z3.object({
|
|
@@ -445,13 +510,25 @@ var daemonMessageSchema = z3.discriminatedUnion("type", [
|
|
|
445
510
|
type: z3.literal("workspace.plan"),
|
|
446
511
|
sessionId: z3.string(),
|
|
447
512
|
requestId: z3.string(),
|
|
448
|
-
plan: z3.string().max(MAX_PLAN_CHARS).nullable()
|
|
513
|
+
plan: z3.string().max(MAX_PLAN_CHARS).nullable(),
|
|
514
|
+
tasks: z3.array(sessionTaskSchema).max(MAX_PLAN_TASKS).default([])
|
|
449
515
|
}),
|
|
450
516
|
z3.object({
|
|
451
517
|
type: z3.literal("project.pathValidated"),
|
|
452
518
|
requestId: z3.string(),
|
|
453
519
|
result: projectPathValidationSchema
|
|
454
520
|
}),
|
|
521
|
+
z3.object({
|
|
522
|
+
type: z3.literal("worktree.listed"),
|
|
523
|
+
requestId: z3.string(),
|
|
524
|
+
worktrees: z3.array(projectWorktreeSchema).max(MAX_PROJECT_WORKTREES),
|
|
525
|
+
truncated: z3.boolean()
|
|
526
|
+
}),
|
|
527
|
+
z3.object({
|
|
528
|
+
type: z3.literal("worktree.removed"),
|
|
529
|
+
requestId: z3.string(),
|
|
530
|
+
result: worktreeRemoveResultSchema
|
|
531
|
+
}),
|
|
455
532
|
z3.object({
|
|
456
533
|
type: z3.literal("daemon.info"),
|
|
457
534
|
hostname: z3.string(),
|
|
@@ -541,7 +618,9 @@ var SIGNABLE_ACTIONS = [
|
|
|
541
618
|
"workspace.getDiff",
|
|
542
619
|
"workspace.readFileRange",
|
|
543
620
|
"workspace.getPlan",
|
|
544
|
-
"project.validatePath"
|
|
621
|
+
"project.validatePath",
|
|
622
|
+
"worktree.list",
|
|
623
|
+
"worktree.remove"
|
|
545
624
|
];
|
|
546
625
|
var envelopeSchema = z4.object({
|
|
547
626
|
version: z4.literal(1),
|
|
@@ -1043,7 +1122,7 @@ async function callTRPCQuery(baseUrl, procedure, input) {
|
|
|
1043
1122
|
// src/pairing/pairing.ts
|
|
1044
1123
|
var DEFAULT_POLL_INTERVAL_MS = 3e3;
|
|
1045
1124
|
function sleep(ms) {
|
|
1046
|
-
return new Promise((
|
|
1125
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1047
1126
|
}
|
|
1048
1127
|
async function ensurePaired(options) {
|
|
1049
1128
|
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
@@ -1181,8 +1260,8 @@ async function bindFirstController(params, response) {
|
|
|
1181
1260
|
}
|
|
1182
1261
|
|
|
1183
1262
|
// src/projects/validate-path.ts
|
|
1184
|
-
import { stat
|
|
1185
|
-
import { isAbsolute
|
|
1263
|
+
import { stat } from "fs/promises";
|
|
1264
|
+
import { isAbsolute, resolve } from "path";
|
|
1186
1265
|
|
|
1187
1266
|
// src/sessions/expand-home.ts
|
|
1188
1267
|
import { homedir } from "os";
|
|
@@ -1252,7 +1331,7 @@ function runGitDiffAllowingExitCodes(args, cwd, allowedExitCodes) {
|
|
|
1252
1331
|
var GIT_INPUT_TIMEOUT_MS = 3e4;
|
|
1253
1332
|
async function runGitWithInput(args, cwd, input, options = {}) {
|
|
1254
1333
|
const { allowedExitCodes = [], timeoutMs = GIT_INPUT_TIMEOUT_MS } = options;
|
|
1255
|
-
return new Promise((
|
|
1334
|
+
return new Promise((resolve3, reject) => {
|
|
1256
1335
|
const child = spawn2("git", [...SAFE_GIT_CONFIG, ...args], { cwd });
|
|
1257
1336
|
const stdout = [];
|
|
1258
1337
|
const stderr = [];
|
|
@@ -1276,7 +1355,7 @@ async function runGitWithInput(args, cwd, input, options = {}) {
|
|
|
1276
1355
|
"close",
|
|
1277
1356
|
(code) => settle(() => {
|
|
1278
1357
|
if (code === 0 || code !== null && allowedExitCodes.includes(code)) {
|
|
1279
|
-
|
|
1358
|
+
resolve3(Buffer.concat(stdout).toString("utf8"));
|
|
1280
1359
|
return;
|
|
1281
1360
|
}
|
|
1282
1361
|
const message = Buffer.concat(stderr).toString("utf8").trim();
|
|
@@ -1287,134 +1366,13 @@ async function runGitWithInput(args, cwd, input, options = {}) {
|
|
|
1287
1366
|
});
|
|
1288
1367
|
}
|
|
1289
1368
|
|
|
1290
|
-
// src/sessions/
|
|
1291
|
-
import { constants as constants2 } from "fs";
|
|
1292
|
-
import { open as open2 } from "fs/promises";
|
|
1369
|
+
// src/sessions/agent-reports/layout.ts
|
|
1293
1370
|
import { homedir as homedir2 } from "os";
|
|
1294
|
-
import { join as
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
import { lstat, open, realpath, stat } from "fs/promises";
|
|
1299
|
-
import { isAbsolute, join as join2, sep } from "path";
|
|
1300
|
-
var MAX_RANGE_FILE_BYTES = 2 * 1024 * 1024;
|
|
1301
|
-
var REPLY_ENVELOPE_BYTES = 8 * 1024;
|
|
1302
|
-
var BINARY_SNIFF_BYTES = 8e3;
|
|
1303
|
-
function fileVersion(stats) {
|
|
1304
|
-
return `${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}:${stats.ino}`;
|
|
1305
|
-
}
|
|
1306
|
-
async function locateInside(cwd, path) {
|
|
1307
|
-
if (isAbsolute(path)) return { refused: "not_allowed" };
|
|
1308
|
-
try {
|
|
1309
|
-
const root = await realpath(cwd);
|
|
1310
|
-
const lexical = join2(root, path);
|
|
1311
|
-
if ((await lstat(lexical)).isSymbolicLink()) return { refused: "not_allowed" };
|
|
1312
|
-
const real = await realpath(lexical);
|
|
1313
|
-
return real === root || real.startsWith(root + sep) ? { found: real } : { refused: "missing" };
|
|
1314
|
-
} catch {
|
|
1315
|
-
return { refused: "missing" };
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
async function readExactly(handle, size) {
|
|
1319
|
-
const buffer = Buffer.allocUnsafe(size + 1);
|
|
1320
|
-
let filled = 0;
|
|
1321
|
-
while (filled < buffer.length) {
|
|
1322
|
-
const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
|
|
1323
|
-
if (bytesRead === 0) break;
|
|
1324
|
-
filled += bytesRead;
|
|
1325
|
-
}
|
|
1326
|
-
return filled === size ? buffer.subarray(0, size) : void 0;
|
|
1327
|
-
}
|
|
1328
|
-
async function currentFileVersion(cwd, path) {
|
|
1329
|
-
const located = await locateInside(cwd, path);
|
|
1330
|
-
if (!("found" in located)) return void 0;
|
|
1331
|
-
try {
|
|
1332
|
-
return fileVersion(await stat(located.found));
|
|
1333
|
-
} catch {
|
|
1334
|
-
return void 0;
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
function splitLines(text) {
|
|
1338
|
-
if (text === "") return [];
|
|
1339
|
-
const lines = text.split("\n");
|
|
1340
|
-
if (lines[lines.length - 1] === "") lines.pop();
|
|
1341
|
-
return lines.map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
1342
|
-
}
|
|
1343
|
-
async function readFileRange(cwd, path, startLine, lineCount, version) {
|
|
1344
|
-
const refuse = (reason) => ({ ok: false, path, reason });
|
|
1345
|
-
const located = await locateInside(cwd, path);
|
|
1346
|
-
if (!("found" in located)) return refuse(located.refused);
|
|
1347
|
-
let content;
|
|
1348
|
-
let handle;
|
|
1349
|
-
try {
|
|
1350
|
-
handle = await open(
|
|
1351
|
-
located.found,
|
|
1352
|
-
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK
|
|
1353
|
-
);
|
|
1354
|
-
const before = await handle.stat();
|
|
1355
|
-
if (!before.isFile()) return refuse("not_allowed");
|
|
1356
|
-
if (fileVersion(before) !== version) return refuse("changed");
|
|
1357
|
-
if (before.size > MAX_RANGE_FILE_BYTES) return refuse("too_large");
|
|
1358
|
-
const bytes = await readExactly(handle, before.size);
|
|
1359
|
-
if (bytes === void 0 || fileVersion(await handle.stat()) !== version) {
|
|
1360
|
-
return refuse("changed");
|
|
1361
|
-
}
|
|
1362
|
-
content = bytes;
|
|
1363
|
-
} catch {
|
|
1364
|
-
return refuse("missing");
|
|
1365
|
-
} finally {
|
|
1366
|
-
await handle?.close();
|
|
1367
|
-
}
|
|
1368
|
-
if (content.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return refuse("binary");
|
|
1369
|
-
const lines = splitLines(content.toString("utf8"));
|
|
1370
|
-
const page = [];
|
|
1371
|
-
let budget = MAX_DAEMON_REPLY_BYTES - REPLY_ENVELOPE_BYTES;
|
|
1372
|
-
const wanted = Math.min(lineCount, MAX_RANGE_LINES);
|
|
1373
|
-
for (let index = startLine - 1; index < lines.length && page.length < wanted; index++) {
|
|
1374
|
-
const line = lines[index];
|
|
1375
|
-
const cost = Buffer.byteLength(JSON.stringify(line)) + 1;
|
|
1376
|
-
if (cost > budget) {
|
|
1377
|
-
if (page.length === 0) return refuse("too_large");
|
|
1378
|
-
break;
|
|
1379
|
-
}
|
|
1380
|
-
budget -= cost;
|
|
1381
|
-
page.push(line);
|
|
1382
|
-
}
|
|
1383
|
-
return { ok: true, path, startLine, lines: page, totalLines: lines.length, version };
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
// src/sessions/files/plan-file.ts
|
|
1371
|
+
import { join as join2 } from "path";
|
|
1372
|
+
var HOOK_PLANS_DIR = join2(homedir2(), ".bondsmith", "plans");
|
|
1373
|
+
var PLAN_FILE_SUFFIX = ".md";
|
|
1374
|
+
var TASKS_FILE_SUFFIX = ".tasks.jsonl";
|
|
1387
1375
|
var PLAN_FILE_PATH = ".bondsmith/plan.md";
|
|
1388
|
-
var MAX_PLAN_BYTES = MAX_PLAN_CHARS * 4;
|
|
1389
|
-
var ABSENT_CODES = /* @__PURE__ */ new Set(["ENOENT", "ELOOP", "ENOTDIR"]);
|
|
1390
|
-
var HOOK_PLANS_DIR = join3(homedir2(), ".bondsmith", "plans");
|
|
1391
|
-
async function readPlanAt(root, relativePath) {
|
|
1392
|
-
const located = await locateInside(root, relativePath);
|
|
1393
|
-
if (!("found" in located)) return null;
|
|
1394
|
-
let handle;
|
|
1395
|
-
try {
|
|
1396
|
-
handle = await open2(
|
|
1397
|
-
located.found,
|
|
1398
|
-
constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK
|
|
1399
|
-
);
|
|
1400
|
-
if (!(await handle.stat()).isFile()) return null;
|
|
1401
|
-
const buffer = Buffer.alloc(MAX_PLAN_BYTES);
|
|
1402
|
-
const { bytesRead } = await handle.read(buffer, 0, MAX_PLAN_BYTES, 0);
|
|
1403
|
-
const plan = buffer.toString("utf8", 0, bytesRead).trim();
|
|
1404
|
-
return plan ? plan.slice(0, MAX_PLAN_CHARS) : null;
|
|
1405
|
-
} catch (error2) {
|
|
1406
|
-
if (ABSENT_CODES.has(error2.code ?? "")) return null;
|
|
1407
|
-
throw error2;
|
|
1408
|
-
} finally {
|
|
1409
|
-
await handle?.close();
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
function readPlanFile(cwd) {
|
|
1413
|
-
return readPlanAt(cwd, PLAN_FILE_PATH);
|
|
1414
|
-
}
|
|
1415
|
-
function readHookPlan(sessionId, plansDir = HOOK_PLANS_DIR) {
|
|
1416
|
-
return readPlanAt(plansDir, `${sessionId}.md`);
|
|
1417
|
-
}
|
|
1418
1376
|
|
|
1419
1377
|
// src/sessions/tmux/client.ts
|
|
1420
1378
|
import { execFile as execFileCb2 } from "child_process";
|
|
@@ -1462,8 +1420,8 @@ async function runTmux(args, options) {
|
|
|
1462
1420
|
throw error2;
|
|
1463
1421
|
}
|
|
1464
1422
|
}
|
|
1465
|
-
async function sendKeysLiteral(tmuxSessionName,
|
|
1466
|
-
await runTmux(["send-keys", "-t", tmuxSessionName, "-l",
|
|
1423
|
+
async function sendKeysLiteral(tmuxSessionName, text2) {
|
|
1424
|
+
await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text2]);
|
|
1467
1425
|
}
|
|
1468
1426
|
async function setMouseMode(tmuxSessionName, enabled) {
|
|
1469
1427
|
await runTmux(["set-option", "-t", tmuxSessionName, "mouse", enabled ? "on" : "off"]);
|
|
@@ -1507,8 +1465,8 @@ var NO_SERVER_MESSAGE_PATTERNS = [
|
|
|
1507
1465
|
function isNoServerError(error2) {
|
|
1508
1466
|
if (typeof error2 !== "object" || error2 === null) return false;
|
|
1509
1467
|
const { message, stderr } = error2;
|
|
1510
|
-
const
|
|
1511
|
-
return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(
|
|
1468
|
+
const text2 = [message, stderr].filter((part) => typeof part === "string").join("\n");
|
|
1469
|
+
return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(text2));
|
|
1512
1470
|
}
|
|
1513
1471
|
|
|
1514
1472
|
// src/sessions/git/base.ts
|
|
@@ -1545,14 +1503,14 @@ async function resolveComparisonBase(cwd) {
|
|
|
1545
1503
|
}
|
|
1546
1504
|
|
|
1547
1505
|
// src/sessions/git/baseline.ts
|
|
1548
|
-
import { lstat
|
|
1549
|
-
import { join as
|
|
1506
|
+
import { lstat } from "fs/promises";
|
|
1507
|
+
import { join as join3 } from "path";
|
|
1550
1508
|
var SESSION_START_REF = "session start";
|
|
1551
1509
|
var MAX_PREEXISTING_FILES = 200;
|
|
1552
1510
|
var DELETED = "deleted";
|
|
1553
1511
|
async function fingerprintFile(cwd, path) {
|
|
1554
1512
|
try {
|
|
1555
|
-
const stats = await
|
|
1513
|
+
const stats = await lstat(join3(cwd, path));
|
|
1556
1514
|
return `${stats.size}:${Math.trunc(stats.mtimeMs)}`;
|
|
1557
1515
|
} catch {
|
|
1558
1516
|
return DELETED;
|
|
@@ -1876,23 +1834,23 @@ function gitFailure(error2, path) {
|
|
|
1876
1834
|
"git isn't installed, or isn't on the PATH of the terminal running bondsmithd."
|
|
1877
1835
|
);
|
|
1878
1836
|
}
|
|
1879
|
-
const
|
|
1880
|
-
if (
|
|
1837
|
+
const text2 = typeof stderr === "string" ? stderr : "";
|
|
1838
|
+
if (text2.includes("dubious ownership")) {
|
|
1881
1839
|
return new Error(
|
|
1882
1840
|
`git doesn't trust this folder because another user owns it. If you trust it, run: git config --global --add safe.directory ${shellQuote(path)}`
|
|
1883
1841
|
);
|
|
1884
1842
|
}
|
|
1885
|
-
const fatal =
|
|
1843
|
+
const fatal = text2.split("\n").find((line) => line.startsWith("fatal:"));
|
|
1886
1844
|
return new Error(
|
|
1887
1845
|
fatal ?? (error2 instanceof Error ? error2.message : "git failed to inspect that folder.")
|
|
1888
1846
|
);
|
|
1889
1847
|
}
|
|
1890
1848
|
async function validateProjectPath(input) {
|
|
1891
1849
|
const expanded = expandHome(input.trim());
|
|
1892
|
-
if (!
|
|
1850
|
+
if (!isAbsolute(expanded)) return { valid: false, reason: "relative_path" };
|
|
1893
1851
|
const path = resolve(expanded);
|
|
1894
1852
|
try {
|
|
1895
|
-
const stats = await
|
|
1853
|
+
const stats = await stat(path);
|
|
1896
1854
|
if (!stats.isDirectory()) return { valid: false, reason: "not_a_directory" };
|
|
1897
1855
|
} catch (error2) {
|
|
1898
1856
|
const reason = invalidReasonForFsError(error2);
|
|
@@ -1919,7 +1877,7 @@ import { randomBytes, verify as verify2 } from "crypto";
|
|
|
1919
1877
|
// src/security/trusted-controllers.ts
|
|
1920
1878
|
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
1921
1879
|
import { homedir as homedir3 } from "os";
|
|
1922
|
-
import { dirname, join as
|
|
1880
|
+
import { dirname, join as join4 } from "path";
|
|
1923
1881
|
import { z as z12 } from "zod";
|
|
1924
1882
|
var trustedControllerSchema = z12.object({
|
|
1925
1883
|
id: z12.string(),
|
|
@@ -1942,7 +1900,7 @@ var trustedControllersFileSchema = z12.object({
|
|
|
1942
1900
|
controllers: z12.array(trustedControllerSchema)
|
|
1943
1901
|
});
|
|
1944
1902
|
function defaultTrustedControllersPath() {
|
|
1945
|
-
return
|
|
1903
|
+
return join4(homedir3(), ".bondsmith", "trusted-controllers.json");
|
|
1946
1904
|
}
|
|
1947
1905
|
async function loadTrustedControllers(path = defaultTrustedControllersPath()) {
|
|
1948
1906
|
let raw;
|
|
@@ -2263,12 +2221,12 @@ var ConnectionAuthState = class {
|
|
|
2263
2221
|
};
|
|
2264
2222
|
|
|
2265
2223
|
// src/sessions/agents/installed-agents.ts
|
|
2266
|
-
import { accessSync, constants
|
|
2267
|
-
import { delimiter, join as
|
|
2268
|
-
var KNOWN_AGENTS =
|
|
2224
|
+
import { accessSync, constants, statSync } from "fs";
|
|
2225
|
+
import { delimiter, join as join5 } from "path";
|
|
2226
|
+
var KNOWN_AGENTS = AGENT_IDS;
|
|
2269
2227
|
function isExecutableFile(path) {
|
|
2270
2228
|
try {
|
|
2271
|
-
accessSync(path,
|
|
2229
|
+
accessSync(path, constants.X_OK);
|
|
2272
2230
|
return statSync(path).isFile();
|
|
2273
2231
|
} catch {
|
|
2274
2232
|
return false;
|
|
@@ -2277,12 +2235,12 @@ function isExecutableFile(path) {
|
|
|
2277
2235
|
function listInstalledAgents(env = process.env) {
|
|
2278
2236
|
const directories = (env.PATH ?? "").split(delimiter).filter((directory) => directory !== "");
|
|
2279
2237
|
return KNOWN_AGENTS.filter(
|
|
2280
|
-
(agent) => directories.some((directory) => isExecutableFile(
|
|
2238
|
+
(agent) => directories.some((directory) => isExecutableFile(join5(directory, agent)))
|
|
2281
2239
|
);
|
|
2282
2240
|
}
|
|
2283
2241
|
|
|
2284
2242
|
// src/version.ts
|
|
2285
|
-
var DAEMON_VERSION = true ? "0.1.
|
|
2243
|
+
var DAEMON_VERSION = true ? "0.1.17" : "dev";
|
|
2286
2244
|
var VERSION_FLAGS = ["--version", "-v"];
|
|
2287
2245
|
function versionLine() {
|
|
2288
2246
|
return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
|
|
@@ -2291,6 +2249,241 @@ function isVersionRequest(argv) {
|
|
|
2291
2249
|
return argv.some((arg) => VERSION_FLAGS.includes(arg));
|
|
2292
2250
|
}
|
|
2293
2251
|
|
|
2252
|
+
// src/sessions/agent-reports/read-file.ts
|
|
2253
|
+
import { constants as constants3 } from "fs";
|
|
2254
|
+
import { open as open2 } from "fs/promises";
|
|
2255
|
+
|
|
2256
|
+
// src/sessions/files/read-range.ts
|
|
2257
|
+
import { constants as constants2 } from "fs";
|
|
2258
|
+
import { lstat as lstat2, open, realpath, stat as stat2 } from "fs/promises";
|
|
2259
|
+
import { isAbsolute as isAbsolute2, join as join6, sep } from "path";
|
|
2260
|
+
var MAX_RANGE_FILE_BYTES = 2 * 1024 * 1024;
|
|
2261
|
+
var REPLY_ENVELOPE_BYTES = 8 * 1024;
|
|
2262
|
+
var BINARY_SNIFF_BYTES = 8e3;
|
|
2263
|
+
function fileVersion(stats) {
|
|
2264
|
+
return `${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}:${stats.ino}`;
|
|
2265
|
+
}
|
|
2266
|
+
async function locateInside(cwd, path) {
|
|
2267
|
+
if (isAbsolute2(path)) return { refused: "not_allowed" };
|
|
2268
|
+
try {
|
|
2269
|
+
const root = await realpath(cwd);
|
|
2270
|
+
const lexical = join6(root, path);
|
|
2271
|
+
if ((await lstat2(lexical)).isSymbolicLink()) return { refused: "not_allowed" };
|
|
2272
|
+
const real = await realpath(lexical);
|
|
2273
|
+
return real === root || real.startsWith(root + sep) ? { found: real } : { refused: "missing" };
|
|
2274
|
+
} catch {
|
|
2275
|
+
return { refused: "missing" };
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
async function readExactly(handle, size) {
|
|
2279
|
+
const buffer = Buffer.allocUnsafe(size + 1);
|
|
2280
|
+
let filled = 0;
|
|
2281
|
+
while (filled < buffer.length) {
|
|
2282
|
+
const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
|
|
2283
|
+
if (bytesRead === 0) break;
|
|
2284
|
+
filled += bytesRead;
|
|
2285
|
+
}
|
|
2286
|
+
return filled === size ? buffer.subarray(0, size) : void 0;
|
|
2287
|
+
}
|
|
2288
|
+
async function currentFileVersion(cwd, path) {
|
|
2289
|
+
const located = await locateInside(cwd, path);
|
|
2290
|
+
if (!("found" in located)) return void 0;
|
|
2291
|
+
try {
|
|
2292
|
+
return fileVersion(await stat2(located.found));
|
|
2293
|
+
} catch {
|
|
2294
|
+
return void 0;
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
function splitLines(text2) {
|
|
2298
|
+
if (text2 === "") return [];
|
|
2299
|
+
const lines = text2.split("\n");
|
|
2300
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
2301
|
+
return lines.map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
2302
|
+
}
|
|
2303
|
+
async function readFileRange(cwd, path, startLine, lineCount, version) {
|
|
2304
|
+
const refuse = (reason) => ({ ok: false, path, reason });
|
|
2305
|
+
const located = await locateInside(cwd, path);
|
|
2306
|
+
if (!("found" in located)) return refuse(located.refused);
|
|
2307
|
+
let content;
|
|
2308
|
+
let handle;
|
|
2309
|
+
try {
|
|
2310
|
+
handle = await open(
|
|
2311
|
+
located.found,
|
|
2312
|
+
constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK
|
|
2313
|
+
);
|
|
2314
|
+
const before = await handle.stat();
|
|
2315
|
+
if (!before.isFile()) return refuse("not_allowed");
|
|
2316
|
+
if (fileVersion(before) !== version) return refuse("changed");
|
|
2317
|
+
if (before.size > MAX_RANGE_FILE_BYTES) return refuse("too_large");
|
|
2318
|
+
const bytes = await readExactly(handle, before.size);
|
|
2319
|
+
if (bytes === void 0 || fileVersion(await handle.stat()) !== version) {
|
|
2320
|
+
return refuse("changed");
|
|
2321
|
+
}
|
|
2322
|
+
content = bytes;
|
|
2323
|
+
} catch {
|
|
2324
|
+
return refuse("missing");
|
|
2325
|
+
} finally {
|
|
2326
|
+
await handle?.close();
|
|
2327
|
+
}
|
|
2328
|
+
if (content.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return refuse("binary");
|
|
2329
|
+
const lines = splitLines(content.toString("utf8"));
|
|
2330
|
+
const page = [];
|
|
2331
|
+
let budget = MAX_DAEMON_REPLY_BYTES - REPLY_ENVELOPE_BYTES;
|
|
2332
|
+
const wanted = Math.min(lineCount, MAX_RANGE_LINES);
|
|
2333
|
+
for (let index = startLine - 1; index < lines.length && page.length < wanted; index++) {
|
|
2334
|
+
const line = lines[index];
|
|
2335
|
+
const cost = Buffer.byteLength(JSON.stringify(line)) + 1;
|
|
2336
|
+
if (cost > budget) {
|
|
2337
|
+
if (page.length === 0) return refuse("too_large");
|
|
2338
|
+
break;
|
|
2339
|
+
}
|
|
2340
|
+
budget -= cost;
|
|
2341
|
+
page.push(line);
|
|
2342
|
+
}
|
|
2343
|
+
return { ok: true, path, startLine, lines: page, totalLines: lines.length, version };
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
// src/sessions/agent-reports/read-file.ts
|
|
2347
|
+
var ABSENT_CODES = /* @__PURE__ */ new Set(["ENOENT", "ELOOP", "ENOTDIR"]);
|
|
2348
|
+
async function readFileAt(root, relativePath, read) {
|
|
2349
|
+
const located = await locateInside(root, relativePath);
|
|
2350
|
+
if (!("found" in located)) return null;
|
|
2351
|
+
let handle;
|
|
2352
|
+
try {
|
|
2353
|
+
handle = await open2(
|
|
2354
|
+
located.found,
|
|
2355
|
+
constants3.O_RDONLY | constants3.O_NOFOLLOW | constants3.O_NONBLOCK
|
|
2356
|
+
);
|
|
2357
|
+
const info = await handle.stat();
|
|
2358
|
+
if (!info.isFile()) return null;
|
|
2359
|
+
return await read(handle, info.size);
|
|
2360
|
+
} catch (error2) {
|
|
2361
|
+
if (ABSENT_CODES.has(error2.code ?? "")) return null;
|
|
2362
|
+
throw error2;
|
|
2363
|
+
} finally {
|
|
2364
|
+
await handle?.close();
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
// src/sessions/agent-reports/task-list.ts
|
|
2369
|
+
import { z as z13 } from "zod";
|
|
2370
|
+
var text = z13.string().refine((value) => value.trim() !== "");
|
|
2371
|
+
var optionalText = text.optional().catch(void 0);
|
|
2372
|
+
var taskStatus = z13.enum(TASK_STATUSES).optional().catch(void 0);
|
|
2373
|
+
var taskId = z13.union([z13.string(), z13.number()]).transform(String);
|
|
2374
|
+
var taskEventSchema = z13.discriminatedUnion("op", [
|
|
2375
|
+
z13.object({
|
|
2376
|
+
op: z13.literal("add"),
|
|
2377
|
+
id: taskId.optional().catch(void 0),
|
|
2378
|
+
subject: text,
|
|
2379
|
+
activeForm: optionalText
|
|
2380
|
+
}),
|
|
2381
|
+
z13.object({
|
|
2382
|
+
op: z13.literal("update"),
|
|
2383
|
+
id: taskId,
|
|
2384
|
+
subject: optionalText,
|
|
2385
|
+
activeForm: optionalText,
|
|
2386
|
+
status: taskStatus
|
|
2387
|
+
}),
|
|
2388
|
+
z13.object({ op: z13.literal("remove"), id: taskId }),
|
|
2389
|
+
z13.object({ op: z13.literal("replace"), tasks: z13.array(z13.unknown()) })
|
|
2390
|
+
]);
|
|
2391
|
+
var replacementTaskSchema = z13.object({
|
|
2392
|
+
subject: text,
|
|
2393
|
+
activeForm: optionalText,
|
|
2394
|
+
status: taskStatus
|
|
2395
|
+
});
|
|
2396
|
+
function applyEvent(tasks, event) {
|
|
2397
|
+
switch (event.op) {
|
|
2398
|
+
case "replace":
|
|
2399
|
+
return event.tasks.flatMap((candidate) => {
|
|
2400
|
+
const task = replacementTaskSchema.safeParse(candidate);
|
|
2401
|
+
if (!task.success) return [];
|
|
2402
|
+
const { subject, activeForm, status = "pending" } = task.data;
|
|
2403
|
+
return [{ id: null, subject, activeForm, status }];
|
|
2404
|
+
});
|
|
2405
|
+
case "add":
|
|
2406
|
+
return [
|
|
2407
|
+
...tasks,
|
|
2408
|
+
{
|
|
2409
|
+
id: event.id ?? null,
|
|
2410
|
+
subject: event.subject,
|
|
2411
|
+
activeForm: event.activeForm,
|
|
2412
|
+
status: "pending"
|
|
2413
|
+
}
|
|
2414
|
+
];
|
|
2415
|
+
case "update":
|
|
2416
|
+
return tasks.map(
|
|
2417
|
+
(task) => task.id === event.id ? {
|
|
2418
|
+
...task,
|
|
2419
|
+
subject: event.subject ?? task.subject,
|
|
2420
|
+
activeForm: event.activeForm ?? task.activeForm,
|
|
2421
|
+
status: event.status ?? task.status
|
|
2422
|
+
} : task
|
|
2423
|
+
);
|
|
2424
|
+
case "remove":
|
|
2425
|
+
return tasks.filter((task) => task.id !== event.id);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
var cut = (value) => value.slice(0, MAX_TASK_TEXT_CHARS);
|
|
2429
|
+
function replayTasks(log) {
|
|
2430
|
+
let tasks = [];
|
|
2431
|
+
for (const line of log.split("\n")) {
|
|
2432
|
+
if (!line.trim()) continue;
|
|
2433
|
+
let raw;
|
|
2434
|
+
try {
|
|
2435
|
+
raw = JSON.parse(line);
|
|
2436
|
+
} catch {
|
|
2437
|
+
continue;
|
|
2438
|
+
}
|
|
2439
|
+
const event = taskEventSchema.safeParse(raw);
|
|
2440
|
+
if (event.success) tasks = applyEvent(tasks, event.data);
|
|
2441
|
+
}
|
|
2442
|
+
return tasks.slice(0, MAX_PLAN_TASKS).map(({ subject, activeForm, status }) => ({
|
|
2443
|
+
subject: cut(subject),
|
|
2444
|
+
...activeForm && { activeForm: cut(activeForm) },
|
|
2445
|
+
status
|
|
2446
|
+
}));
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// src/sessions/agent-reports/readers.ts
|
|
2450
|
+
var MAX_PLAN_BYTES = MAX_PLAN_CHARS * 4;
|
|
2451
|
+
var MAX_TASK_LOG_BYTES = 1024 * 1024;
|
|
2452
|
+
async function readPlanAt(root, relativePath) {
|
|
2453
|
+
const text2 = await readFileAt(root, relativePath, async (handle) => {
|
|
2454
|
+
const buffer = Buffer.alloc(MAX_PLAN_BYTES);
|
|
2455
|
+
const { bytesRead } = await handle.read(buffer, 0, MAX_PLAN_BYTES, 0);
|
|
2456
|
+
return buffer.toString("utf8", 0, bytesRead).trim();
|
|
2457
|
+
});
|
|
2458
|
+
return text2 ? text2.slice(0, MAX_PLAN_CHARS) : null;
|
|
2459
|
+
}
|
|
2460
|
+
function readPlanFile(cwd) {
|
|
2461
|
+
return readPlanAt(cwd, PLAN_FILE_PATH);
|
|
2462
|
+
}
|
|
2463
|
+
function readHookPlan(sessionId, plansDir = HOOK_PLANS_DIR) {
|
|
2464
|
+
return readPlanAt(plansDir, `${sessionId}${PLAN_FILE_SUFFIX}`);
|
|
2465
|
+
}
|
|
2466
|
+
async function readHookTasks(sessionId, plansDir = HOOK_PLANS_DIR) {
|
|
2467
|
+
const log = await readFileAt(
|
|
2468
|
+
plansDir,
|
|
2469
|
+
`${sessionId}${TASKS_FILE_SUFFIX}`,
|
|
2470
|
+
async (handle, size) => {
|
|
2471
|
+
const length = Math.min(size, MAX_TASK_LOG_BYTES);
|
|
2472
|
+
const buffer = Buffer.alloc(length);
|
|
2473
|
+
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
|
|
2474
|
+
const text2 = buffer.toString("utf8", 0, bytesRead);
|
|
2475
|
+
return length < size ? text2.slice(text2.indexOf("\n") + 1) : text2;
|
|
2476
|
+
}
|
|
2477
|
+
);
|
|
2478
|
+
return log === null ? [] : replayTasks(log);
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
// src/sessions/agent-reports/session-report.ts
|
|
2482
|
+
async function readSessionReport(session) {
|
|
2483
|
+
const plan = await readHookPlan(session.id) ?? await readPlanFile(session.cwd) ?? await getSessionPlan(session.tmuxSessionName);
|
|
2484
|
+
return { plan, tasks: await readHookTasks(session.id) };
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2294
2487
|
// src/security/verify-command.ts
|
|
2295
2488
|
import { verify as verify3 } from "crypto";
|
|
2296
2489
|
var ENVELOPE_MAX_AGE_MS = 6e4;
|
|
@@ -2427,8 +2620,8 @@ function createOutputPacer(options) {
|
|
|
2427
2620
|
|
|
2428
2621
|
// src/ws/sanitize-insert-text.ts
|
|
2429
2622
|
var DISALLOWED_CHARACTERS = /[\x00-\x08\x0b-\x1f\x7f-\x9f--]/g;
|
|
2430
|
-
function sanitizeInsertText(
|
|
2431
|
-
const withLineFeeds =
|
|
2623
|
+
function sanitizeInsertText(text2) {
|
|
2624
|
+
const withLineFeeds = text2.replace(/\r\n?/g, "\n");
|
|
2432
2625
|
const sanitized = withLineFeeds.replace(DISALLOWED_CHARACTERS, "");
|
|
2433
2626
|
return { text: sanitized, strippedCount: withLineFeeds.length - sanitized.length };
|
|
2434
2627
|
}
|
|
@@ -2520,6 +2713,19 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
|
|
|
2520
2713
|
}
|
|
2521
2714
|
return { conn, releaseAllSubscriptions };
|
|
2522
2715
|
}
|
|
2716
|
+
var removalQueues = /* @__PURE__ */ new Map();
|
|
2717
|
+
function inRepoOrder(repoPath, task) {
|
|
2718
|
+
const result = (removalQueues.get(repoPath) ?? Promise.resolve()).then(task);
|
|
2719
|
+
const tail = result.then(
|
|
2720
|
+
() => void 0,
|
|
2721
|
+
() => void 0
|
|
2722
|
+
);
|
|
2723
|
+
removalQueues.set(repoPath, tail);
|
|
2724
|
+
void tail.then(() => {
|
|
2725
|
+
if (removalQueues.get(repoPath) === tail) removalQueues.delete(repoPath);
|
|
2726
|
+
});
|
|
2727
|
+
return result;
|
|
2728
|
+
}
|
|
2523
2729
|
function error(message, requestId) {
|
|
2524
2730
|
return { type: "error", message, requestId };
|
|
2525
2731
|
}
|
|
@@ -2649,6 +2855,8 @@ async function dispatch(conn, message, {
|
|
|
2649
2855
|
getWorkspaceDiff: getWorkspaceDiff2,
|
|
2650
2856
|
readFileRange: readFileRange2,
|
|
2651
2857
|
validateProjectPath: validateProjectPath2,
|
|
2858
|
+
listProjectWorktrees: listProjectWorktrees2,
|
|
2859
|
+
removeManagedWorktree: removeManagedWorktree2,
|
|
2652
2860
|
hostname: hostname3,
|
|
2653
2861
|
daemonFingerprint,
|
|
2654
2862
|
pendingAuthorizations,
|
|
@@ -2762,14 +2970,14 @@ async function dispatch(conn, message, {
|
|
|
2762
2970
|
const session = getSessionOrError(sessionManager, conn, message.sessionId);
|
|
2763
2971
|
if (!session) return;
|
|
2764
2972
|
sessionManager.acknowledge(message.sessionId);
|
|
2765
|
-
const { text, strippedCount } = sanitizeInsertText(message.text);
|
|
2973
|
+
const { text: text2, strippedCount } = sanitizeInsertText(message.text);
|
|
2766
2974
|
if (strippedCount > 0) {
|
|
2767
2975
|
console.warn(`bondsmithd: stripped ${strippedCount} control characters from inserted text`);
|
|
2768
2976
|
}
|
|
2769
2977
|
try {
|
|
2770
2978
|
await sendKeysLiteral(
|
|
2771
2979
|
session.tmuxSessionName,
|
|
2772
|
-
`${BRACKETED_PASTE_START}${
|
|
2980
|
+
`${BRACKETED_PASTE_START}${text2}${BRACKETED_PASTE_END}`
|
|
2773
2981
|
);
|
|
2774
2982
|
} catch (err) {
|
|
2775
2983
|
conn.send(error(errorMessageOf(err, "failed to insert text")));
|
|
@@ -2880,12 +3088,13 @@ async function dispatch(conn, message, {
|
|
|
2880
3088
|
const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
|
|
2881
3089
|
if (!session) return;
|
|
2882
3090
|
try {
|
|
2883
|
-
const plan
|
|
3091
|
+
const { plan, tasks } = await readSessionReport(session);
|
|
2884
3092
|
conn.send({
|
|
2885
3093
|
type: "workspace.plan",
|
|
2886
3094
|
sessionId: message.sessionId,
|
|
2887
3095
|
requestId: message.requestId,
|
|
2888
|
-
plan
|
|
3096
|
+
plan,
|
|
3097
|
+
tasks
|
|
2889
3098
|
});
|
|
2890
3099
|
} catch (err) {
|
|
2891
3100
|
conn.send(error(errorMessageOf(err, "failed to read the session plan"), message.requestId));
|
|
@@ -2901,6 +3110,47 @@ async function dispatch(conn, message, {
|
|
|
2901
3110
|
}
|
|
2902
3111
|
return;
|
|
2903
3112
|
}
|
|
3113
|
+
case "worktree.list": {
|
|
3114
|
+
try {
|
|
3115
|
+
const project = await validateProjectPath2(message.repoPath);
|
|
3116
|
+
if (!project.valid) {
|
|
3117
|
+
conn.send(error(`can't list worktrees: ${project.reason}`, message.requestId));
|
|
3118
|
+
return;
|
|
3119
|
+
}
|
|
3120
|
+
const worktrees = await listProjectWorktrees2(project.path, sessionManager.list());
|
|
3121
|
+
conn.send({
|
|
3122
|
+
type: "worktree.listed",
|
|
3123
|
+
requestId: message.requestId,
|
|
3124
|
+
...fitWorktreeReply(worktrees)
|
|
3125
|
+
});
|
|
3126
|
+
} catch (err) {
|
|
3127
|
+
conn.send(error(errorMessageOf(err, "failed to list worktrees"), message.requestId));
|
|
3128
|
+
}
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
case "worktree.remove": {
|
|
3132
|
+
try {
|
|
3133
|
+
const project = await validateProjectPath2(message.repoPath);
|
|
3134
|
+
if (!project.valid) {
|
|
3135
|
+
conn.send(error(`can't remove a worktree: ${project.reason}`, message.requestId));
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
const result = await inRepoOrder(
|
|
3139
|
+
project.path,
|
|
3140
|
+
() => removeManagedWorktree2({
|
|
3141
|
+
repoPath: project.path,
|
|
3142
|
+
worktreePath: message.worktreePath,
|
|
3143
|
+
force: message.force,
|
|
3144
|
+
deleteBranch: message.deleteBranch,
|
|
3145
|
+
getSessions: () => sessionManager.list()
|
|
3146
|
+
})
|
|
3147
|
+
);
|
|
3148
|
+
conn.send({ type: "worktree.removed", requestId: message.requestId, result });
|
|
3149
|
+
} catch (err) {
|
|
3150
|
+
conn.send(error(errorMessageOf(err, "failed to remove the worktree"), message.requestId));
|
|
3151
|
+
}
|
|
3152
|
+
return;
|
|
3153
|
+
}
|
|
2904
3154
|
case "daemon.getInfo": {
|
|
2905
3155
|
conn.send({ type: "daemon.info", hostname: hostname3(), daemonFingerprint });
|
|
2906
3156
|
return;
|
|
@@ -3319,9 +3569,9 @@ function isMatchingKeyPair(publicKeyPem, privateKeyPem) {
|
|
|
3319
3569
|
return false;
|
|
3320
3570
|
}
|
|
3321
3571
|
}
|
|
3322
|
-
function extractPemBlock(
|
|
3572
|
+
function extractPemBlock(text2, label) {
|
|
3323
3573
|
const pattern = new RegExp(`-----BEGIN ${label}-----[\\s\\S]*?-----END ${label}-----\\n?`);
|
|
3324
|
-
return
|
|
3574
|
+
return text2.match(pattern)?.[0] ?? null;
|
|
3325
3575
|
}
|
|
3326
3576
|
|
|
3327
3577
|
// src/security/daemon-proof.ts
|
|
@@ -3460,8 +3710,8 @@ var PendingAuthorizationStore = class {
|
|
|
3460
3710
|
requestToken: randomUUID()
|
|
3461
3711
|
};
|
|
3462
3712
|
let settleFn;
|
|
3463
|
-
const resultPromise = new Promise((
|
|
3464
|
-
settleFn =
|
|
3713
|
+
const resultPromise = new Promise((resolve3) => {
|
|
3714
|
+
settleFn = resolve3;
|
|
3465
3715
|
});
|
|
3466
3716
|
const entry = {
|
|
3467
3717
|
authorization,
|
|
@@ -3572,16 +3822,16 @@ var PendingAuthorizationStore = class {
|
|
|
3572
3822
|
};
|
|
3573
3823
|
|
|
3574
3824
|
// src/security/revocation-sync.ts
|
|
3575
|
-
import { z as
|
|
3825
|
+
import { z as z14 } from "zod";
|
|
3576
3826
|
var POLL_INTERVAL_MS = 3e4;
|
|
3577
3827
|
var POLL_JITTER_MS = 6e3;
|
|
3578
3828
|
var POLL_REQUEST_TIMEOUT_MS = 1e4;
|
|
3579
3829
|
var BACKOFF_BASE_MS = 2e3;
|
|
3580
3830
|
var BACKOFF_MAX_MS = 2e4;
|
|
3581
3831
|
var MAX_CONSECUTIVE_FAILURE_BACKOFFS = 4;
|
|
3582
|
-
var checkRevocationStatusResponseSchema =
|
|
3583
|
-
pairingRevoked:
|
|
3584
|
-
revokedControllerPublicKeys:
|
|
3832
|
+
var checkRevocationStatusResponseSchema = z14.object({
|
|
3833
|
+
pairingRevoked: z14.boolean(),
|
|
3834
|
+
revokedControllerPublicKeys: z14.array(z14.string())
|
|
3585
3835
|
});
|
|
3586
3836
|
function jitteredInterval(deps) {
|
|
3587
3837
|
const interval = deps.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
@@ -3801,6 +4051,256 @@ async function getWorkspaceDiff(cwd, path, staged, baseline) {
|
|
|
3801
4051
|
}
|
|
3802
4052
|
}
|
|
3803
4053
|
|
|
4054
|
+
// src/sessions/git/managed-worktree.ts
|
|
4055
|
+
import { realpath as realpath4 } from "fs/promises";
|
|
4056
|
+
import { sep as sep2 } from "path";
|
|
4057
|
+
|
|
4058
|
+
// src/sessions/git/worktree.ts
|
|
4059
|
+
import { execFile as execFileCb3 } from "child_process";
|
|
4060
|
+
import { promisify as promisify3 } from "util";
|
|
4061
|
+
var execFile3 = promisify3(execFileCb3);
|
|
4062
|
+
var WorktreeBranchNameTakenError = class extends Error {
|
|
4063
|
+
constructor(branch) {
|
|
4064
|
+
super(`a branch named "${branch}" already exists`);
|
|
4065
|
+
this.name = "WorktreeBranchNameTakenError";
|
|
4066
|
+
}
|
|
4067
|
+
};
|
|
4068
|
+
var WorktreePathExistsError = class extends Error {
|
|
4069
|
+
constructor(path) {
|
|
4070
|
+
super(`worktree path already exists: ${path}`);
|
|
4071
|
+
this.name = "WorktreePathExistsError";
|
|
4072
|
+
}
|
|
4073
|
+
};
|
|
4074
|
+
var WorktreeHasUncommittedChangesError = class extends Error {
|
|
4075
|
+
constructor(path) {
|
|
4076
|
+
super(
|
|
4077
|
+
`worktree at ${path} has uncommitted or untracked changes \u2014 not removing it. Commit or discard the changes, then remove it yourself (git worktree remove ${path}).`
|
|
4078
|
+
);
|
|
4079
|
+
this.name = "WorktreeHasUncommittedChangesError";
|
|
4080
|
+
}
|
|
4081
|
+
};
|
|
4082
|
+
function stderrOf(error2) {
|
|
4083
|
+
if (typeof error2 !== "object" || error2 === null) return "";
|
|
4084
|
+
const stderr = error2.stderr;
|
|
4085
|
+
return typeof stderr === "string" ? stderr : "";
|
|
4086
|
+
}
|
|
4087
|
+
async function createWorktree(sourceCwd, worktreePath, branch, startPoint) {
|
|
4088
|
+
const args = ["worktree", "add", "--no-track", "-b", branch, worktreePath];
|
|
4089
|
+
if (startPoint) args.push(startPoint);
|
|
4090
|
+
try {
|
|
4091
|
+
await execFile3("git", [...SAFE_GIT_CONFIG, ...args], { cwd: sourceCwd });
|
|
4092
|
+
} catch (error2) {
|
|
4093
|
+
const stderr = stderrOf(error2);
|
|
4094
|
+
if (stderr.includes("a branch named")) {
|
|
4095
|
+
throw new WorktreeBranchNameTakenError(branch);
|
|
4096
|
+
}
|
|
4097
|
+
if (stderr.includes("already exists")) {
|
|
4098
|
+
throw new WorktreePathExistsError(worktreePath);
|
|
4099
|
+
}
|
|
4100
|
+
throw error2;
|
|
4101
|
+
}
|
|
4102
|
+
}
|
|
4103
|
+
async function removeWorktree(sourceCwd, worktreePath, { force = false } = {}) {
|
|
4104
|
+
const args = ["worktree", "remove", ...force ? ["--force"] : [], "--", worktreePath];
|
|
4105
|
+
try {
|
|
4106
|
+
await execFile3("git", [...SAFE_GIT_CONFIG, ...args], { cwd: sourceCwd });
|
|
4107
|
+
} catch (error2) {
|
|
4108
|
+
const stderr = stderrOf(error2);
|
|
4109
|
+
if (stderr.includes("contains modified or untracked files")) {
|
|
4110
|
+
throw new WorktreeHasUncommittedChangesError(worktreePath);
|
|
4111
|
+
}
|
|
4112
|
+
throw error2;
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
var BranchNotMergedError = class extends Error {
|
|
4116
|
+
constructor(branch) {
|
|
4117
|
+
super(`the branch "${branch}" is not fully merged, so it was kept`);
|
|
4118
|
+
this.name = "BranchNotMergedError";
|
|
4119
|
+
}
|
|
4120
|
+
};
|
|
4121
|
+
async function deleteMergedBranch(sourceCwd, branch) {
|
|
4122
|
+
try {
|
|
4123
|
+
await execFile3("git", [...SAFE_GIT_CONFIG, "branch", "-d", "--", branch], { cwd: sourceCwd });
|
|
4124
|
+
} catch (error2) {
|
|
4125
|
+
if (stderrOf(error2).includes("not fully merged")) throw new BranchNotMergedError(branch);
|
|
4126
|
+
throw error2;
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
// src/sessions/git/worktree-list.ts
|
|
4131
|
+
import { realpath as realpath3 } from "fs/promises";
|
|
4132
|
+
|
|
4133
|
+
// src/sessions/worktree-root.ts
|
|
4134
|
+
import { lstat as lstat3, realpath as realpath2 } from "fs/promises";
|
|
4135
|
+
import { homedir as homedir6 } from "os";
|
|
4136
|
+
import { basename, dirname as dirname4, isAbsolute as isAbsolute3, join as join9, resolve as resolve2 } from "path";
|
|
4137
|
+
function worktreeRoot() {
|
|
4138
|
+
return join9(homedir6(), ".bondsmith", "worktrees");
|
|
4139
|
+
}
|
|
4140
|
+
function worktreePathFor(sessionId) {
|
|
4141
|
+
return join9(worktreeRoot(), sessionId);
|
|
4142
|
+
}
|
|
4143
|
+
async function canonicalPath(path) {
|
|
4144
|
+
try {
|
|
4145
|
+
return await realpath2(path);
|
|
4146
|
+
} catch (error2) {
|
|
4147
|
+
const code = error2.code;
|
|
4148
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error2;
|
|
4149
|
+
const parent = dirname4(path);
|
|
4150
|
+
if (parent === path) return path;
|
|
4151
|
+
return join9(await canonicalPath(parent), basename(path));
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
async function managedWorktreeLocation(path, root = worktreeRoot()) {
|
|
4155
|
+
if (!isAbsolute3(path) || path.includes("\0") || path !== resolve2(path)) return null;
|
|
4156
|
+
const name = basename(path);
|
|
4157
|
+
if (!isValidSessionId(name)) return null;
|
|
4158
|
+
const realRoot = await canonicalPath(root);
|
|
4159
|
+
if (await canonicalPath(dirname4(path)) !== realRoot) return null;
|
|
4160
|
+
return join9(realRoot, name);
|
|
4161
|
+
}
|
|
4162
|
+
async function requireManagedWorktreeDirectory(path, root = worktreeRoot()) {
|
|
4163
|
+
const location = await managedWorktreeLocation(path, root);
|
|
4164
|
+
if (!location) return null;
|
|
4165
|
+
try {
|
|
4166
|
+
if (!(await lstat3(location)).isDirectory()) return null;
|
|
4167
|
+
return await realpath2(location);
|
|
4168
|
+
} catch (error2) {
|
|
4169
|
+
if (error2.code === "ENOENT") return null;
|
|
4170
|
+
throw error2;
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
|
|
4174
|
+
// src/sessions/git/worktree-list.ts
|
|
4175
|
+
var BRANCH_REF_PREFIX = "refs/heads/";
|
|
4176
|
+
function parseWorktreeList(output) {
|
|
4177
|
+
const entries = [];
|
|
4178
|
+
let current = null;
|
|
4179
|
+
for (const field of output.split("\0")) {
|
|
4180
|
+
if (field === "") {
|
|
4181
|
+
if (current) entries.push(current);
|
|
4182
|
+
current = null;
|
|
4183
|
+
} else if (field.startsWith("worktree ")) {
|
|
4184
|
+
current = {
|
|
4185
|
+
path: field.slice("worktree ".length),
|
|
4186
|
+
branch: null,
|
|
4187
|
+
head: "",
|
|
4188
|
+
prunable: false,
|
|
4189
|
+
locked: false
|
|
4190
|
+
};
|
|
4191
|
+
} else if (current && field.startsWith("HEAD ")) {
|
|
4192
|
+
current.head = field.slice("HEAD ".length);
|
|
4193
|
+
} else if (current && field.startsWith("branch ")) {
|
|
4194
|
+
const ref = field.slice("branch ".length);
|
|
4195
|
+
current.branch = ref.startsWith(BRANCH_REF_PREFIX) ? ref.slice(BRANCH_REF_PREFIX.length) : ref;
|
|
4196
|
+
} else if (current && (field === "prunable" || field.startsWith("prunable "))) {
|
|
4197
|
+
current.prunable = true;
|
|
4198
|
+
} else if (current && (field === "locked" || field.startsWith("locked "))) {
|
|
4199
|
+
current.locked = true;
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
if (current) entries.push(current);
|
|
4203
|
+
return entries;
|
|
4204
|
+
}
|
|
4205
|
+
async function listDaemonWorktrees(repoPath) {
|
|
4206
|
+
const [, ...linked] = parseWorktreeList(
|
|
4207
|
+
await runGit(["worktree", "list", "--porcelain", "-z"], repoPath)
|
|
4208
|
+
);
|
|
4209
|
+
const ownPath = await realpath3(repoPath);
|
|
4210
|
+
const locations = await Promise.all(linked.map((entry) => managedWorktreeLocation(entry.path)));
|
|
4211
|
+
return linked.flatMap((entry, index) => {
|
|
4212
|
+
const location = locations[index];
|
|
4213
|
+
return location && location !== ownPath ? [{ ...entry, path: location }] : [];
|
|
4214
|
+
});
|
|
4215
|
+
}
|
|
4216
|
+
async function findManagedWorktree(repoPath, requestedPath) {
|
|
4217
|
+
const location = await requireManagedWorktreeDirectory(requestedPath);
|
|
4218
|
+
if (!location) return null;
|
|
4219
|
+
const entries = await listDaemonWorktrees(repoPath);
|
|
4220
|
+
return entries.find((entry) => entry.path === location) ?? null;
|
|
4221
|
+
}
|
|
4222
|
+
|
|
4223
|
+
// src/sessions/git/managed-worktree.ts
|
|
4224
|
+
var STATUS_CONCURRENCY = 8;
|
|
4225
|
+
async function mapWithConcurrency(items, limit, map) {
|
|
4226
|
+
const results = new Array(items.length);
|
|
4227
|
+
let next = 0;
|
|
4228
|
+
let failed = false;
|
|
4229
|
+
const worker = async () => {
|
|
4230
|
+
while (!failed && next < items.length) {
|
|
4231
|
+
const index = next++;
|
|
4232
|
+
try {
|
|
4233
|
+
results[index] = await map(items[index]);
|
|
4234
|
+
} catch (error2) {
|
|
4235
|
+
failed = true;
|
|
4236
|
+
throw error2;
|
|
4237
|
+
}
|
|
4238
|
+
}
|
|
4239
|
+
};
|
|
4240
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
4241
|
+
return results;
|
|
4242
|
+
}
|
|
4243
|
+
async function sessionInWorktree(worktreePath, sessions) {
|
|
4244
|
+
for (const session of sessions) {
|
|
4245
|
+
const real = await realpath4(session.cwd).catch(() => session.cwd);
|
|
4246
|
+
const inside = [session.cwd, real].some(
|
|
4247
|
+
(cwd) => cwd === worktreePath || cwd.startsWith(`${worktreePath}${sep2}`)
|
|
4248
|
+
);
|
|
4249
|
+
if (inside) return session;
|
|
4250
|
+
}
|
|
4251
|
+
return null;
|
|
4252
|
+
}
|
|
4253
|
+
async function hasChanges(worktreePath) {
|
|
4254
|
+
try {
|
|
4255
|
+
return (await runGit(["status", "--porcelain", "--untracked-files=normal"], worktreePath)).trim() !== "";
|
|
4256
|
+
} catch {
|
|
4257
|
+
return true;
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4260
|
+
async function listProjectWorktrees(repoPath, sessions) {
|
|
4261
|
+
const entries = await listDaemonWorktrees(repoPath);
|
|
4262
|
+
return mapWithConcurrency(entries, STATUS_CONCURRENCY, async (entry) => ({
|
|
4263
|
+
path: entry.path,
|
|
4264
|
+
branch: entry.branch,
|
|
4265
|
+
head: entry.head,
|
|
4266
|
+
prunable: entry.prunable,
|
|
4267
|
+
locked: entry.locked,
|
|
4268
|
+
dirty: entry.prunable ? false : await hasChanges(entry.path),
|
|
4269
|
+
sessionId: (await sessionInWorktree(entry.path, sessions))?.id ?? null
|
|
4270
|
+
}));
|
|
4271
|
+
}
|
|
4272
|
+
async function dropBranch(repoPath, branch) {
|
|
4273
|
+
try {
|
|
4274
|
+
await deleteMergedBranch(repoPath, branch);
|
|
4275
|
+
return "deleted";
|
|
4276
|
+
} catch (error2) {
|
|
4277
|
+
if (error2 instanceof BranchNotMergedError) return "not_merged";
|
|
4278
|
+
console.error("Failed to delete the branch of a removed worktree", { branch, error: error2 });
|
|
4279
|
+
return "failed";
|
|
4280
|
+
}
|
|
4281
|
+
}
|
|
4282
|
+
async function removeManagedWorktree(input) {
|
|
4283
|
+
const entry = await findManagedWorktree(input.repoPath, input.worktreePath);
|
|
4284
|
+
if (!entry) return { removed: false, reason: "not_found" };
|
|
4285
|
+
if (entry.locked) return { removed: false, reason: "locked" };
|
|
4286
|
+
if (await sessionInWorktree(entry.path, input.getSessions())) {
|
|
4287
|
+
return { removed: false, reason: "session_active" };
|
|
4288
|
+
}
|
|
4289
|
+
try {
|
|
4290
|
+
await removeWorktree(input.repoPath, entry.path, { force: input.force });
|
|
4291
|
+
} catch (error2) {
|
|
4292
|
+
if (error2 instanceof WorktreeHasUncommittedChangesError) {
|
|
4293
|
+
return { removed: false, reason: "has_changes" };
|
|
4294
|
+
}
|
|
4295
|
+
throw error2;
|
|
4296
|
+
}
|
|
4297
|
+
const branch = input.deleteBranch && entry.branch ? await dropBranch(input.repoPath, entry.branch) : "kept";
|
|
4298
|
+
console.log(
|
|
4299
|
+
`Removed worktree ${entry.path} (branch ${entry.branch ?? "none"}: ${branch}${input.force ? ", forced" : ""})`
|
|
4300
|
+
);
|
|
4301
|
+
return { removed: true, branch };
|
|
4302
|
+
}
|
|
4303
|
+
|
|
3804
4304
|
// src/ws/server.ts
|
|
3805
4305
|
import { hostname } from "os";
|
|
3806
4306
|
import { WebSocketServer } from "ws";
|
|
@@ -3931,63 +4431,43 @@ ${new TmuxNotFoundError().message}
|
|
|
3931
4431
|
// src/sessions/session-manager.ts
|
|
3932
4432
|
import { v4 as uuidv4 } from "uuid";
|
|
3933
4433
|
|
|
3934
|
-
// src/sessions/
|
|
3935
|
-
import {
|
|
3936
|
-
import {
|
|
3937
|
-
var
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
var WorktreePathExistsError = class extends Error {
|
|
3945
|
-
constructor(path) {
|
|
3946
|
-
super(`worktree path already exists: ${path}`);
|
|
3947
|
-
this.name = "WorktreePathExistsError";
|
|
3948
|
-
}
|
|
3949
|
-
};
|
|
3950
|
-
var WorktreeHasUncommittedChangesError = class extends Error {
|
|
3951
|
-
constructor(path) {
|
|
3952
|
-
super(
|
|
3953
|
-
`worktree at ${path} has uncommitted or untracked changes \u2014 not removing it. Commit or discard the changes, then remove it yourself (git worktree remove ${path}).`
|
|
3954
|
-
);
|
|
3955
|
-
this.name = "WorktreeHasUncommittedChangesError";
|
|
3956
|
-
}
|
|
3957
|
-
};
|
|
3958
|
-
function stderrOf(error2) {
|
|
3959
|
-
if (typeof error2 !== "object" || error2 === null) return "";
|
|
3960
|
-
const stderr = error2.stderr;
|
|
3961
|
-
return typeof stderr === "string" ? stderr : "";
|
|
4434
|
+
// src/sessions/agent-reports/lifecycle.ts
|
|
4435
|
+
import { readdir, rm as rm4, stat as stat3 } from "fs/promises";
|
|
4436
|
+
import { basename as basename2, join as join10 } from "path";
|
|
4437
|
+
var SWEEP_MIN_AGE_MS = 6e4;
|
|
4438
|
+
async function removeHookPlanFiles(sessionId, plansDir = HOOK_PLANS_DIR) {
|
|
4439
|
+
await Promise.all(
|
|
4440
|
+
[PLAN_FILE_SUFFIX, TASKS_FILE_SUFFIX].map(
|
|
4441
|
+
(suffix) => rm4(join10(plansDir, `${basename2(sessionId)}${suffix}`), { force: true })
|
|
4442
|
+
)
|
|
4443
|
+
);
|
|
3962
4444
|
}
|
|
3963
|
-
|
|
3964
|
-
const
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
await execFile3("git", [...SAFE_GIT_CONFIG, ...args], { cwd: sourceCwd });
|
|
3968
|
-
} catch (error2) {
|
|
3969
|
-
const stderr = stderrOf(error2);
|
|
3970
|
-
if (stderr.includes("a branch named")) {
|
|
3971
|
-
throw new WorktreeBranchNameTakenError(branch);
|
|
3972
|
-
}
|
|
3973
|
-
if (stderr.includes("already exists")) {
|
|
3974
|
-
throw new WorktreePathExistsError(worktreePath);
|
|
3975
|
-
}
|
|
3976
|
-
throw error2;
|
|
4445
|
+
function hookFileSessionId(name) {
|
|
4446
|
+
for (const suffix of [PLAN_FILE_SUFFIX, TASKS_FILE_SUFFIX]) {
|
|
4447
|
+
const sessionId = name.endsWith(suffix) ? name.slice(0, -suffix.length) : "";
|
|
4448
|
+
if (sessionId && !sessionId.includes(".")) return sessionId;
|
|
3977
4449
|
}
|
|
4450
|
+
return null;
|
|
3978
4451
|
}
|
|
3979
|
-
async function
|
|
4452
|
+
async function sweepOrphanedHookPlanFiles(liveSessionIds, plansDir = HOOK_PLANS_DIR) {
|
|
4453
|
+
let names;
|
|
3980
4454
|
try {
|
|
3981
|
-
await
|
|
3982
|
-
cwd: sourceCwd
|
|
3983
|
-
});
|
|
4455
|
+
names = await readdir(plansDir);
|
|
3984
4456
|
} catch (error2) {
|
|
3985
|
-
|
|
3986
|
-
if (stderr.includes("contains modified or untracked files")) {
|
|
3987
|
-
throw new WorktreeHasUncommittedChangesError(worktreePath);
|
|
3988
|
-
}
|
|
4457
|
+
if (ABSENT_CODES.has(error2.code ?? "")) return;
|
|
3989
4458
|
throw error2;
|
|
3990
4459
|
}
|
|
4460
|
+
const now = Date.now();
|
|
4461
|
+
await Promise.all(
|
|
4462
|
+
names.map(async (name) => {
|
|
4463
|
+
const sessionId = hookFileSessionId(name);
|
|
4464
|
+
if (sessionId === null || liveSessionIds.has(sessionId)) return;
|
|
4465
|
+
const path = join10(plansDir, name);
|
|
4466
|
+
const modifiedMs = await stat3(path).then((info) => info.mtimeMs).catch(() => null);
|
|
4467
|
+
if (modifiedMs === null || now - modifiedMs < SWEEP_MIN_AGE_MS) return;
|
|
4468
|
+
await rm4(path, { force: true });
|
|
4469
|
+
})
|
|
4470
|
+
);
|
|
3991
4471
|
}
|
|
3992
4472
|
|
|
3993
4473
|
// src/sessions/worktree-setup/setup-command.ts
|
|
@@ -4029,22 +4509,15 @@ function decodeSkippedFiles(raw) {
|
|
|
4029
4509
|
|
|
4030
4510
|
// src/sessions/tmux/agent-state.ts
|
|
4031
4511
|
var AGENT_STATE_OPTION = "@bondsmith-agent-state";
|
|
4032
|
-
var HEADLINE_OPTION = "@bondsmith-headline";
|
|
4033
4512
|
var PULL_REQUESTS_OPTION = "@bondsmith-prs";
|
|
4034
4513
|
var PULL_REQUEST_URL = /https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+/g;
|
|
4035
|
-
var
|
|
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}}`;
|
|
4514
|
+
var LIVE_SESSION_REPORTS_FORMAT = `#{session_name} #{${AGENT_STATE_OPTION}} #{window_bell_flag} #{${SETUP_FAILED_OPTION}} #{${SETUP_SKIPPED_OPTION}} #{${PULL_REQUESTS_OPTION}}`;
|
|
4037
4515
|
var AGENT_STATE_OUTPUT_GRACE_MS = 2e3;
|
|
4038
4516
|
var FUTURE_REPORT_ALLOWANCE_MS = 2e3;
|
|
4039
4517
|
var WORKING_SILENCE_MS = 12e4;
|
|
4040
4518
|
var SETTLE_QUIET_MS = 8e3;
|
|
4041
4519
|
var ACKNOWLEDGE_GRACE_MS = 3e3;
|
|
4042
4520
|
var SETTLED_STATES = /* @__PURE__ */ new Set(["waiting", "done"]);
|
|
4043
|
-
function cleanHeadline(raw) {
|
|
4044
|
-
const line = raw.replace(/\s+/g, " ").trim();
|
|
4045
|
-
if (!line) return null;
|
|
4046
|
-
return line.length > MAX_HEADLINE_CHARS ? `${line.slice(0, MAX_HEADLINE_CHARS - 1)}\u2026` : line;
|
|
4047
|
-
}
|
|
4048
4521
|
function parsePullRequestUrls(raw) {
|
|
4049
4522
|
const urls = [...new Set(raw.match(PULL_REQUEST_URL) ?? [])];
|
|
4050
4523
|
return urls.slice(-MAX_SESSION_PULL_REQUESTS);
|
|
@@ -4061,8 +4534,7 @@ function parseLiveSessionReports(output) {
|
|
|
4061
4534
|
bell = "",
|
|
4062
4535
|
setupFailed = "",
|
|
4063
4536
|
setupSkipped = "",
|
|
4064
|
-
pullRequests = ""
|
|
4065
|
-
...headline
|
|
4537
|
+
pullRequests = ""
|
|
4066
4538
|
] = line.split(" ");
|
|
4067
4539
|
if (sessionName) {
|
|
4068
4540
|
reports.set(sessionName, {
|
|
@@ -4070,8 +4542,7 @@ function parseLiveSessionReports(output) {
|
|
|
4070
4542
|
bell: bell === "1",
|
|
4071
4543
|
setupFailed: setupFailed === "1",
|
|
4072
4544
|
setupSkipped: decodeSkippedFiles(setupSkipped),
|
|
4073
|
-
pullRequestUrls: parsePullRequestUrls(pullRequests)
|
|
4074
|
-
headline: cleanHeadline(headline.join(" "))
|
|
4545
|
+
pullRequestUrls: parsePullRequestUrls(pullRequests)
|
|
4075
4546
|
});
|
|
4076
4547
|
}
|
|
4077
4548
|
}
|
|
@@ -4122,56 +4593,67 @@ function resolveSessionAttention(report, lastActivityAt, { nowMs, acknowledgedAt
|
|
|
4122
4593
|
|
|
4123
4594
|
// src/sessions/tmux/create.ts
|
|
4124
4595
|
import { access } from "fs/promises";
|
|
4125
|
-
import { homedir as homedir7 } from "os";
|
|
4126
|
-
import { join as join11 } from "path";
|
|
4127
4596
|
|
|
4128
|
-
// src/sessions/agents/
|
|
4597
|
+
// src/sessions/agents/adapter.ts
|
|
4598
|
+
var ENV_ASSIGNMENT_WORD = String.raw`\w+=\S*`;
|
|
4599
|
+
var ENV_ASSIGNMENT = new RegExp(`^${ENV_ASSIGNMENT_WORD}$`);
|
|
4600
|
+
function leadingBinary(command) {
|
|
4601
|
+
return command.split(/\s+/).filter(Boolean).find((word) => !ENV_ASSIGNMENT.test(word));
|
|
4602
|
+
}
|
|
4603
|
+
function plainAdapter(id) {
|
|
4604
|
+
return {
|
|
4605
|
+
id,
|
|
4606
|
+
matches: (command) => leadingBinary(command) === id,
|
|
4607
|
+
prepareCommand: async (command) => command
|
|
4608
|
+
};
|
|
4609
|
+
}
|
|
4610
|
+
|
|
4611
|
+
// src/sessions/agents/claude/hooks.ts
|
|
4129
4612
|
import { execFile as execFile4 } from "child_process";
|
|
4130
4613
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
4131
|
-
import { mkdir as mkdir4, rename as rename4, rm as
|
|
4132
|
-
import { homedir as
|
|
4133
|
-
import { join as
|
|
4614
|
+
import { mkdir as mkdir4, rename as rename4, rm as rm5, writeFile as writeFile4 } from "fs/promises";
|
|
4615
|
+
import { homedir as homedir7 } from "os";
|
|
4616
|
+
import { join as join11 } from "path";
|
|
4134
4617
|
import { promisify as promisify4 } from "util";
|
|
4135
4618
|
|
|
4136
4619
|
// src/sessions/session-env.ts
|
|
4137
4620
|
var SESSION_ENV_VAR = "BONDSMITH_SESSION";
|
|
4138
4621
|
|
|
4139
|
-
// src/sessions/
|
|
4140
|
-
|
|
4141
|
-
var MIN_HEADLINE_CHARS = 4;
|
|
4142
|
-
function planHookScript(plansDir) {
|
|
4622
|
+
// src/sessions/agent-reports/hook-scripts.ts
|
|
4623
|
+
function storePlanScript(plansDir, planExpr) {
|
|
4143
4624
|
return [
|
|
4144
4625
|
`let d="";`,
|
|
4145
4626
|
`process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
|
|
4146
|
-
`const
|
|
4627
|
+
`const j=JSON.parse(d),p=${planExpr};if(typeof p!=="string"||!p)return;`,
|
|
4147
4628
|
`const fs=require("fs"),path=require("path"),dir=${JSON.stringify(plansDir)};`,
|
|
4148
4629
|
// basename keeps an odd session value from naming a file outside the plans folder.
|
|
4149
|
-
`const name=path.basename(process.env.${SESSION_ENV_VAR})
|
|
4630
|
+
`const name=path.basename(process.env.${SESSION_ENV_VAR})+${JSON.stringify(PLAN_FILE_SUFFIX)},tmp=path.join(dir,"."+name+"."+process.pid);`,
|
|
4150
4631
|
// Written aside and renamed: a reader never sees half a plan, and a symlink in the way is replaced, not followed.
|
|
4151
4632
|
`fs.mkdirSync(dir,{recursive:true,mode:0o700});`,
|
|
4152
4633
|
`fs.writeFileSync(tmp,p,{mode:0o600});fs.renameSync(tmp,path.join(dir,name))`,
|
|
4153
4634
|
`}catch{}})`
|
|
4154
4635
|
].join("");
|
|
4155
4636
|
}
|
|
4156
|
-
function
|
|
4637
|
+
function appendTaskEventScript(plansDir, eventExpr) {
|
|
4157
4638
|
return [
|
|
4158
4639
|
`let d="";`,
|
|
4159
4640
|
`process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
|
|
4160
|
-
`const
|
|
4161
|
-
`const
|
|
4162
|
-
`
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
`
|
|
4641
|
+
`const j=JSON.parse(d),e=${eventExpr};if(!e)return;`,
|
|
4642
|
+
`const fs=require("fs"),path=require("path"),dir=${JSON.stringify(plansDir)},c=fs.constants;`,
|
|
4643
|
+
`fs.mkdirSync(dir,{recursive:true,mode:0o700});`,
|
|
4644
|
+
// O_NOFOLLOW so a symlink in the way is refused rather than written through.
|
|
4645
|
+
`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);`,
|
|
4646
|
+
`try{fs.writeSync(fd,JSON.stringify(e)+"\\n")}finally{fs.closeSync(fd)}`,
|
|
4166
4647
|
`}catch{}})`
|
|
4167
4648
|
].join("");
|
|
4168
4649
|
}
|
|
4650
|
+
var PR_CREATE_MENTION = "gh pr create";
|
|
4169
4651
|
var PR_CREATE_COMMAND = /(?:^|[;&|\n(])\s*(?:\w+=\S*\s+)*gh\s+pr\s+create\b/;
|
|
4170
|
-
function
|
|
4652
|
+
function recordPullRequestScript(commandExpr, outputExpr) {
|
|
4171
4653
|
return [
|
|
4172
4654
|
`let d="";`,
|
|
4173
4655
|
`process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
|
|
4174
|
-
`const j=JSON.parse(d),c
|
|
4656
|
+
`const j=JSON.parse(d),c=${commandExpr},o=${outputExpr};`,
|
|
4175
4657
|
`if(typeof c!=="string"||typeof o!=="string"||!${PR_CREATE_COMMAND.toString()}.test(c))return;`,
|
|
4176
4658
|
`const m=o.match(${PULL_REQUEST_URL.toString()});if(!m)return;const u=m[m.length-1];`,
|
|
4177
4659
|
`const cp=require("child_process"),p=process.env.TMUX_PANE;`,
|
|
@@ -4182,29 +4664,49 @@ function pullRequestHookScript() {
|
|
|
4182
4664
|
].join("");
|
|
4183
4665
|
}
|
|
4184
4666
|
|
|
4185
|
-
// src/sessions/
|
|
4667
|
+
// src/sessions/agent-reports/hook-commands.ts
|
|
4186
4668
|
function shellQuote3(value) {
|
|
4187
4669
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
4188
4670
|
}
|
|
4189
4671
|
function nodeHookCommand(script, nodePath) {
|
|
4190
4672
|
return `[ -n "$${SESSION_ENV_VAR}" ] && ${shellQuote3(nodePath)} -e ${shellQuote3(script)}; exit 0`;
|
|
4191
4673
|
}
|
|
4674
|
+
function recordPullRequestCommand(script, nodePath) {
|
|
4675
|
+
const run = `${shellQuote3(nodePath)} -e ${shellQuote3(script)}`;
|
|
4676
|
+
return `[ -n "$${SESSION_ENV_VAR}" ] && { input=$(cat); case "$input" in *"${PR_CREATE_MENTION}"*) printf '%s' "$input" | ${run};; esac; }; exit 0`;
|
|
4677
|
+
}
|
|
4678
|
+
function stateHookCommand(state) {
|
|
4679
|
+
return `[ -n "$${SESSION_ENV_VAR}" ] && tmux set-option -t "$TMUX_PANE" -q ${AGENT_STATE_OPTION} "${state}:$(date +%s)"; exit 0`;
|
|
4680
|
+
}
|
|
4681
|
+
|
|
4682
|
+
// src/sessions/agents/claude/scripts.ts
|
|
4683
|
+
var PLAN = `j.tool_response?.plan??j.tool_input?.plan`;
|
|
4684
|
+
var TASK_EVENT_BY_TOOL = {
|
|
4685
|
+
TaskCreate: `{op:"add",id:j.tool_response?.task?.id,subject:i.subject,activeForm:i.activeForm}`,
|
|
4686
|
+
TaskUpdate: `i.status==="deleted"?{op:"remove",id:i.taskId}:{op:"update",id:i.taskId,subject:i.subject,status:i.status,activeForm:i.activeForm}`,
|
|
4687
|
+
TodoWrite: `Array.isArray(i.todos)?{op:"replace",tasks:i.todos.map(t=>({subject:t?.content,status:t?.status,activeForm:t?.activeForm}))}:undefined`
|
|
4688
|
+
};
|
|
4689
|
+
var TASK_TOOLS = Object.keys(TASK_EVENT_BY_TOOL);
|
|
4690
|
+
var TASK_EVENT = `(()=>{const i=j.tool_input||{};switch(j.tool_name){${Object.entries(
|
|
4691
|
+
TASK_EVENT_BY_TOOL
|
|
4692
|
+
).map(([tool, event]) => `case${JSON.stringify(tool)}:return ${event};`).join("")}}})()`;
|
|
4693
|
+
var planHookScript = (plansDir) => storePlanScript(plansDir, PLAN);
|
|
4694
|
+
var taskHookScript = (plansDir) => appendTaskEventScript(plansDir, TASK_EVENT);
|
|
4695
|
+
var pullRequestHookScript = () => recordPullRequestScript(`j.tool_input?.command`, `j.tool_response?.stdout`);
|
|
4696
|
+
|
|
4697
|
+
// src/sessions/agents/claude/settings.ts
|
|
4192
4698
|
function planHookCommand(nodePath = process.execPath, plansDir = HOOK_PLANS_DIR) {
|
|
4193
4699
|
return nodeHookCommand(planHookScript(plansDir), nodePath);
|
|
4194
4700
|
}
|
|
4195
|
-
function
|
|
4196
|
-
return nodeHookCommand(
|
|
4701
|
+
function taskHookCommand(nodePath = process.execPath, plansDir = HOOK_PLANS_DIR) {
|
|
4702
|
+
return nodeHookCommand(taskHookScript(plansDir), nodePath);
|
|
4197
4703
|
}
|
|
4198
4704
|
function pullRequestHookCommand(nodePath = process.execPath) {
|
|
4199
|
-
|
|
4200
|
-
return `[ -n "$${SESSION_ENV_VAR}" ] && { input=$(cat); case "$input" in *"gh pr create"*) printf '%s' "$input" | ${run};; esac; }; exit 0`;
|
|
4201
|
-
}
|
|
4202
|
-
function stateHookCommand(state) {
|
|
4203
|
-
return `[ -n "$${SESSION_ENV_VAR}" ] && tmux set-option -t "$TMUX_PANE" -q ${AGENT_STATE_OPTION} "${state}:$(date +%s)"; exit 0`;
|
|
4705
|
+
return recordPullRequestCommand(pullRequestHookScript(), nodePath);
|
|
4204
4706
|
}
|
|
4205
4707
|
var STATE_HOOK_TIMEOUT_SECONDS = 5;
|
|
4206
4708
|
var PLAN_HOOK_TIMEOUT_SECONDS = 10;
|
|
4207
|
-
var
|
|
4709
|
+
var TASK_HOOK_TIMEOUT_SECONDS = 10;
|
|
4208
4710
|
var PULL_REQUEST_HOOK_TIMEOUT_SECONDS = 5;
|
|
4209
4711
|
function claudeSettings() {
|
|
4210
4712
|
const run = (command, timeout) => ({ type: "command", command, timeout });
|
|
@@ -4216,38 +4718,43 @@ function claudeSettings() {
|
|
|
4216
4718
|
matcher: "ExitPlanMode",
|
|
4217
4719
|
hooks: [run(planHookCommand(), PLAN_HOOK_TIMEOUT_SECONDS)]
|
|
4218
4720
|
},
|
|
4721
|
+
{
|
|
4722
|
+
matcher: TASK_TOOLS.join("|"),
|
|
4723
|
+
hooks: [run(taskHookCommand(), TASK_HOOK_TIMEOUT_SECONDS)]
|
|
4724
|
+
},
|
|
4219
4725
|
{
|
|
4220
4726
|
matcher: "Bash",
|
|
4221
4727
|
hooks: [run(pullRequestHookCommand(), PULL_REQUEST_HOOK_TIMEOUT_SECONDS)]
|
|
4222
4728
|
}
|
|
4223
4729
|
],
|
|
4224
|
-
// Working from the moment a prompt is sent
|
|
4225
|
-
UserPromptSubmit: [
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
}
|
|
4730
|
+
// Working from the moment a prompt is sent; waiting when it needs the user; done when a turn ends.
|
|
4731
|
+
UserPromptSubmit: [{ hooks: [report("working")] }],
|
|
4732
|
+
// The plan is stored while it waits for approval, so it shows before the user decides.
|
|
4733
|
+
PermissionRequest: [
|
|
4734
|
+
{ matcher: "*", hooks: [report("waiting")] },
|
|
4735
|
+
{ matcher: "ExitPlanMode", hooks: [run(planHookCommand(), PLAN_HOOK_TIMEOUT_SECONDS)] }
|
|
4229
4736
|
],
|
|
4230
|
-
PermissionRequest: [{ matcher: "*", hooks: [report("waiting")] }],
|
|
4231
4737
|
Stop: [{ hooks: [report("done")] }],
|
|
4232
4738
|
StopFailure: [{ hooks: [report("waiting")] }]
|
|
4233
4739
|
}
|
|
4234
4740
|
};
|
|
4235
4741
|
}
|
|
4236
4742
|
|
|
4237
|
-
// src/sessions/agents/claude
|
|
4743
|
+
// src/sessions/agents/claude/hooks.ts
|
|
4238
4744
|
var execFileAsync = promisify4(execFile4);
|
|
4239
|
-
var CLAUDE_HOOKS_DIR =
|
|
4745
|
+
var CLAUDE_HOOKS_DIR = join11(homedir7(), ".bondsmith", "hooks");
|
|
4240
4746
|
var AGENT_HOOKS_ENV_VAR = "BONDSMITH_AGENT_HOOKS";
|
|
4241
4747
|
var HOOKS_OFF_VALUES = /^(off|0|false|no)$/i;
|
|
4242
4748
|
var CLAUDE_BINARY = "claude";
|
|
4243
|
-
var LEADING_CLAUDE = new RegExp(
|
|
4749
|
+
var LEADING_CLAUDE = new RegExp(
|
|
4750
|
+
`^(\\s*(?:${ENV_ASSIGNMENT_WORD}\\s+)*)${CLAUDE_BINARY}(?=\\s|$)`
|
|
4751
|
+
);
|
|
4244
4752
|
var SETTINGS_FLAG = "--settings";
|
|
4245
4753
|
var SUPPORT_PROBE_TIMEOUT_MS = 1e4;
|
|
4246
4754
|
var SUPPORT_RETRY_AFTER_MS = 6e4;
|
|
4247
4755
|
function isPlainClaudeCommand(command) {
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
return !words.some((word) => word === SETTINGS_FLAG || word.startsWith(`${SETTINGS_FLAG}=`));
|
|
4756
|
+
if (leadingBinary(command) !== CLAUDE_BINARY) return false;
|
|
4757
|
+
return !command.trim().split(/\s+/).some((word) => word === SETTINGS_FLAG || word.startsWith(`${SETTINGS_FLAG}=`));
|
|
4251
4758
|
}
|
|
4252
4759
|
async function probeSettingsSupport() {
|
|
4253
4760
|
try {
|
|
@@ -4288,14 +4795,14 @@ async function prepareAgentCommand(command, {
|
|
|
4288
4795
|
if (HOOKS_OFF_VALUES.test(env[AGENT_HOOKS_ENV_VAR] ?? "")) return command;
|
|
4289
4796
|
if (!isPlainClaudeCommand(command)) return command;
|
|
4290
4797
|
if (!await supportsSettings()) return command;
|
|
4291
|
-
const file =
|
|
4798
|
+
const file = join11(hooksDir, "claude-settings.json");
|
|
4292
4799
|
const staging = `${file}.${randomUUID2()}.tmp`;
|
|
4293
4800
|
try {
|
|
4294
4801
|
await mkdir4(hooksDir, { recursive: true });
|
|
4295
4802
|
await writeFile4(staging, JSON.stringify(claudeSettings(), null, " "));
|
|
4296
4803
|
await rename4(staging, file);
|
|
4297
4804
|
} catch (error2) {
|
|
4298
|
-
await
|
|
4805
|
+
await rm5(staging, { force: true }).catch(() => void 0);
|
|
4299
4806
|
console.error(
|
|
4300
4807
|
"could not write the Claude Code hook settings; starting claude without them",
|
|
4301
4808
|
error2
|
|
@@ -4308,6 +4815,29 @@ async function prepareAgentCommand(command, {
|
|
|
4308
4815
|
);
|
|
4309
4816
|
}
|
|
4310
4817
|
|
|
4818
|
+
// src/sessions/agents/claude/adapter.ts
|
|
4819
|
+
var claudeAdapter = {
|
|
4820
|
+
id: "claude",
|
|
4821
|
+
matches: (command) => leadingBinary(command) === "claude",
|
|
4822
|
+
prepareCommand: (command) => prepareAgentCommand(command)
|
|
4823
|
+
};
|
|
4824
|
+
|
|
4825
|
+
// src/sessions/agents/registry.ts
|
|
4826
|
+
var ADAPTERS = {
|
|
4827
|
+
claude: claudeAdapter,
|
|
4828
|
+
codex: plainAdapter("codex"),
|
|
4829
|
+
opencode: plainAdapter("opencode")
|
|
4830
|
+
};
|
|
4831
|
+
function adapterFor(command) {
|
|
4832
|
+
return Object.values(ADAPTERS).find((adapter) => adapter.matches(command));
|
|
4833
|
+
}
|
|
4834
|
+
function agentIdFor(command) {
|
|
4835
|
+
return adapterFor(command)?.id ?? null;
|
|
4836
|
+
}
|
|
4837
|
+
async function prepareCommand(command) {
|
|
4838
|
+
return await adapterFor(command)?.prepareCommand(command) ?? command;
|
|
4839
|
+
}
|
|
4840
|
+
|
|
4311
4841
|
// src/sessions/git/start-point.ts
|
|
4312
4842
|
var WorktreeBaseBranchNotFoundError = class extends Error {
|
|
4313
4843
|
constructor(branch) {
|
|
@@ -4333,13 +4863,13 @@ async function resolveWorktreeStartPoint(sourceCwd, baseBranch) {
|
|
|
4333
4863
|
|
|
4334
4864
|
// src/sessions/worktree-setup/copy-files.ts
|
|
4335
4865
|
import { constants as constants4 } from "fs";
|
|
4336
|
-
import { copyFile, glob, lstat as
|
|
4337
|
-
import { dirname as
|
|
4866
|
+
import { copyFile, glob, lstat as lstat4, mkdir as mkdir5, realpath as realpath5 } from "fs/promises";
|
|
4867
|
+
import { dirname as dirname5, isAbsolute as isAbsolute4, join as join12, relative, sep as sep3 } from "path";
|
|
4338
4868
|
var GIT_EXIT_NONE_IGNORED = 1;
|
|
4339
4869
|
var TRACKED_CHECK_BATCH = 200;
|
|
4340
4870
|
function isInside(root, path) {
|
|
4341
4871
|
const rel = relative(root, path);
|
|
4342
|
-
return rel !== ".." && !rel.startsWith(`..${
|
|
4872
|
+
return rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute4(rel);
|
|
4343
4873
|
}
|
|
4344
4874
|
async function ignoredPaths(worktree, paths) {
|
|
4345
4875
|
if (paths.length === 0) return /* @__PURE__ */ new Set();
|
|
@@ -4368,16 +4898,16 @@ async function trackedPaths(worktree, paths) {
|
|
|
4368
4898
|
}
|
|
4369
4899
|
return tracked;
|
|
4370
4900
|
}
|
|
4371
|
-
async function copyFilesIntoWorktree(sourceRoot,
|
|
4901
|
+
async function copyFilesIntoWorktree(sourceRoot, worktreeRoot2, globs) {
|
|
4372
4902
|
const result = { copied: [], failed: 0, skipped: [] };
|
|
4373
4903
|
const patterns = globs.filter(isSafeCopyGlob);
|
|
4374
4904
|
if (patterns.length === 0) return result;
|
|
4375
4905
|
try {
|
|
4376
|
-
const source = await
|
|
4377
|
-
const worktree = await
|
|
4906
|
+
const source = await realpath5(sourceRoot);
|
|
4907
|
+
const worktree = await realpath5(worktreeRoot2);
|
|
4378
4908
|
const candidates = [];
|
|
4379
4909
|
for await (const match of glob(patterns, { cwd: source })) {
|
|
4380
|
-
if (match.split(
|
|
4910
|
+
if (match.split(sep3).includes(".git")) continue;
|
|
4381
4911
|
try {
|
|
4382
4912
|
if (await isCopyCandidate(source, worktree, match)) candidates.push(match);
|
|
4383
4913
|
} catch (error2) {
|
|
@@ -4417,11 +4947,11 @@ async function copyFilesIntoWorktree(sourceRoot, worktreeRoot, globs) {
|
|
|
4417
4947
|
return result;
|
|
4418
4948
|
}
|
|
4419
4949
|
async function isCopyCandidate(source, worktree, match) {
|
|
4420
|
-
const from =
|
|
4421
|
-
if (!(await
|
|
4422
|
-
if (!isInside(source, await
|
|
4950
|
+
const from = join12(source, match);
|
|
4951
|
+
if (!(await lstat4(from)).isFile()) return false;
|
|
4952
|
+
if (!isInside(source, await realpath5(from))) return false;
|
|
4423
4953
|
try {
|
|
4424
|
-
await
|
|
4954
|
+
await lstat4(join12(worktree, match));
|
|
4425
4955
|
return false;
|
|
4426
4956
|
} catch (error2) {
|
|
4427
4957
|
if (error2.code === "ENOENT") return true;
|
|
@@ -4429,11 +4959,11 @@ async function isCopyCandidate(source, worktree, match) {
|
|
|
4429
4959
|
}
|
|
4430
4960
|
}
|
|
4431
4961
|
async function copyOne(source, worktree, match) {
|
|
4432
|
-
const to =
|
|
4433
|
-
await mkdir5(
|
|
4434
|
-
if (!isInside(worktree, await
|
|
4962
|
+
const to = join12(worktree, match);
|
|
4963
|
+
await mkdir5(dirname5(to), { recursive: true });
|
|
4964
|
+
if (!isInside(worktree, await realpath5(dirname5(to)))) return false;
|
|
4435
4965
|
try {
|
|
4436
|
-
await copyFile(
|
|
4966
|
+
await copyFile(join12(source, match), to, constants4.COPYFILE_EXCL);
|
|
4437
4967
|
} catch (error2) {
|
|
4438
4968
|
if (error2.code === "EEXIST") return false;
|
|
4439
4969
|
throw error2;
|
|
@@ -4448,9 +4978,6 @@ var InvalidCwdError = class extends Error {
|
|
|
4448
4978
|
this.name = "InvalidCwdError";
|
|
4449
4979
|
}
|
|
4450
4980
|
};
|
|
4451
|
-
function worktreePathFor(sessionId) {
|
|
4452
|
-
return join11(homedir7(), ".bondsmith", "worktrees", sessionId);
|
|
4453
|
-
}
|
|
4454
4981
|
async function createTmuxSession(input) {
|
|
4455
4982
|
const sourceCwd = expandHome(input.cwd);
|
|
4456
4983
|
let cwd = sourceCwd;
|
|
@@ -4481,7 +5008,7 @@ async function createTmuxSession(input) {
|
|
|
4481
5008
|
const tmuxSessionName = tmuxSessionNameFor(input.id);
|
|
4482
5009
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4483
5010
|
const baseline = await captureBaseline(cwd, { freshCheckout: worktreeOf !== null });
|
|
4484
|
-
const agentCommand = await
|
|
5011
|
+
const agentCommand = await prepareCommand(input.command);
|
|
4485
5012
|
const setupCommand = worktreeOf ? input.worktreeSetup?.setupCommand?.trim() : void 0;
|
|
4486
5013
|
const launchCommand = setupCommand ? wrapCommandWithSetup({ setupCommand, command: agentCommand, tmuxSessionName }) : agentCommand;
|
|
4487
5014
|
await runTmux(
|
|
@@ -4530,6 +5057,7 @@ async function createTmuxSession(input) {
|
|
|
4530
5057
|
id: input.id,
|
|
4531
5058
|
title: input.title,
|
|
4532
5059
|
command: input.command,
|
|
5060
|
+
agent: agentIdFor(input.command),
|
|
4533
5061
|
cwd,
|
|
4534
5062
|
tmuxSessionName,
|
|
4535
5063
|
status: "running",
|
|
@@ -4566,6 +5094,7 @@ function parseTmuxSessions(output) {
|
|
|
4566
5094
|
id,
|
|
4567
5095
|
title,
|
|
4568
5096
|
command,
|
|
5097
|
+
agent: agentIdFor(command),
|
|
4569
5098
|
cwd,
|
|
4570
5099
|
tmuxSessionName: sessionName,
|
|
4571
5100
|
status: "running",
|
|
@@ -4649,6 +5178,9 @@ var SessionManager = class {
|
|
|
4649
5178
|
for (const [id, baseline] of discovered.baselines) {
|
|
4650
5179
|
this.baselines.set(id, baseline);
|
|
4651
5180
|
}
|
|
5181
|
+
sweepOrphanedHookPlanFiles(new Set(this.sessions.keys())).catch(
|
|
5182
|
+
(error2) => console.error("Failed to sweep stored plans of ended sessions", error2)
|
|
5183
|
+
);
|
|
4652
5184
|
}
|
|
4653
5185
|
list() {
|
|
4654
5186
|
return Array.from(this.sessions.values());
|
|
@@ -4744,7 +5276,6 @@ ${error2.message}
|
|
|
4744
5276
|
agentState: resolveSessionAttention(report, enriched.lastActivityAt, {
|
|
4745
5277
|
acknowledgedAtMs: this.acknowledgements.get(session.id) ?? null
|
|
4746
5278
|
}),
|
|
4747
|
-
headline: report?.headline ?? null,
|
|
4748
5279
|
...report?.pullRequestUrls?.length && { pullRequestUrls: report.pullRequestUrls },
|
|
4749
5280
|
...report?.setupFailed && { worktreeSetupFailed: true },
|
|
4750
5281
|
...report?.setupSkipped?.length && { worktreeSetupSkipped: report.setupSkipped }
|
|
@@ -4767,12 +5298,15 @@ ${error2.message}
|
|
|
4767
5298
|
this.sessions.delete(session.id);
|
|
4768
5299
|
this.baselines.delete(session.id);
|
|
4769
5300
|
this.acknowledgements.delete(session.id);
|
|
5301
|
+
removeHookPlanFiles(session.id).catch(
|
|
5302
|
+
(error2) => console.error("Failed to remove the session's stored plan", { sessionId: session.id, error: error2 })
|
|
5303
|
+
);
|
|
4770
5304
|
this.emitUpdated(updated);
|
|
4771
5305
|
return updated;
|
|
4772
5306
|
}
|
|
4773
5307
|
};
|
|
4774
5308
|
function snapshotEquals(a, b) {
|
|
4775
|
-
return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt && (a.agentState ?? null) === (b.agentState ?? null) &&
|
|
5309
|
+
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 ?? []);
|
|
4776
5310
|
}
|
|
4777
5311
|
|
|
4778
5312
|
// src/ws/server.ts
|
|
@@ -4896,6 +5430,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
4896
5430
|
getWorkspaceDiff,
|
|
4897
5431
|
readFileRange,
|
|
4898
5432
|
validateProjectPath,
|
|
5433
|
+
listProjectWorktrees,
|
|
5434
|
+
removeManagedWorktree,
|
|
4899
5435
|
hostname: () => hostname(),
|
|
4900
5436
|
daemonFingerprint,
|
|
4901
5437
|
pendingAuthorizations,
|
|
@@ -4939,10 +5475,10 @@ if (isVersionRequest(process.argv.slice(2))) {
|
|
|
4939
5475
|
process.exit(0);
|
|
4940
5476
|
}
|
|
4941
5477
|
function loadRepoRootEnv() {
|
|
4942
|
-
const repoRoot =
|
|
4943
|
-
if (!existsSync(
|
|
4944
|
-
const envLocal =
|
|
4945
|
-
const envDefault =
|
|
5478
|
+
const repoRoot = join13(dirname6(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
5479
|
+
if (!existsSync(join13(repoRoot, "pnpm-workspace.yaml"))) return;
|
|
5480
|
+
const envLocal = join13(repoRoot, ".env.local");
|
|
5481
|
+
const envDefault = join13(repoRoot, ".env");
|
|
4946
5482
|
loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
|
|
4947
5483
|
}
|
|
4948
5484
|
loadRepoRootEnv();
|
|
@@ -5001,6 +5537,8 @@ async function start() {
|
|
|
5001
5537
|
getWorkspaceDiff,
|
|
5002
5538
|
readFileRange,
|
|
5003
5539
|
validateProjectPath,
|
|
5540
|
+
listProjectWorktrees,
|
|
5541
|
+
removeManagedWorktree,
|
|
5004
5542
|
hostname: hostname2,
|
|
5005
5543
|
pendingAuthorizations,
|
|
5006
5544
|
presentPendingAuthorization,
|