@bondsmith/bondsmithd 0.1.15 → 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 +34 -11
- package/dist/index.js +477 -248
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -143,21 +143,17 @@ A hook can store the plan outside the repository instead, in `~/.bondsmith/plans
|
|
|
143
143
|
option, so a plan written there is never left over from an earlier session.
|
|
144
144
|
|
|
145
145
|
For Claude Code the daemon sets this up itself. When a session's command is `claude` (that word alone, not
|
|
146
|
-
behind `npx
|
|
146
|
+
behind `npx` or a wrapper; `VAR=value` words before it are fine), the daemon starts it as
|
|
147
147
|
`claude --settings ~/.bondsmith/hooks/claude-settings.json …`. That file adds hooks to the ones you
|
|
148
|
-
already have:
|
|
149
|
-
|
|
148
|
+
already have: one stores the plan as above as soon as plan mode proposes it, before you approve it;
|
|
149
|
+
another logs the agent's task calls (`TaskCreate`, `TaskUpdate`, `TodoWrite`) to `~/.bondsmith/plans/<session id>.tasks.jsonl`,
|
|
150
|
+
which the daemon replays into the session's task list (shown under the plan in the browser); and four
|
|
151
|
+
report the agent state (`working` on a prompt; `waiting` on a permission request, the end of a turn, or a turn that fails with
|
|
150
152
|
an API error). Nothing in `~/.claude` changes, the session still shows the command you typed, and the hooks
|
|
151
153
|
do nothing outside a Bondsmith session. Claude Code's `/hooks` menu does not list hooks passed with
|
|
152
154
|
`--settings`, though they run.
|
|
153
155
|
|
|
154
|
-
A fifth hook
|
|
155
|
-
show. It is the prompt on one line, cut at 200 characters, in the tmux option `@bondsmith-headline`. A reply
|
|
156
|
-
that only answers the agent (`yes`, `continue`, `ok`, `thanks`, and a few like them) or a prompt under 4
|
|
157
|
-
characters leaves the earlier headline in place; a built-in slash command such as `/help` sends no prompt to
|
|
158
|
-
the hook, so it does too. The prompt is stored as typed, so a secret in a prompt is in the headline.
|
|
159
|
-
|
|
160
|
-
A sixth hook watches Bash calls for `gh pr create`. When one succeeds and prints a pull request URL, the URL is
|
|
156
|
+
A fifth hook watches Bash calls for `gh pr create`. When one succeeds and prints a pull request URL, the URL is
|
|
161
157
|
appended to the tmux option `@bondsmith-prs` (space separated, each once, the newest 20 kept), and the daemon
|
|
162
158
|
reports it on the session as `pullRequestUrls`. It matches `gh pr create` only as a command of its own, so
|
|
163
159
|
`echo "gh pr create"` or `gh pr view` record nothing, and a failed command never reaches it.
|
|
@@ -165,7 +161,7 @@ reports it on the session as `pullRequestUrls`. It matches `gh pr create` only a
|
|
|
165
161
|
The daemon leaves the command alone if it already has `--settings`, if the installed `claude` does not list
|
|
166
162
|
`--settings` in `claude --help` (asked once per daemon run; if `claude` cannot be run at all, that is logged
|
|
167
163
|
and asked again after a minute), or if `BONDSMITH_AGENT_HOOKS` is set to `off`, `0`, `false` or `no` where
|
|
168
|
-
the daemon starts. Each hook has a timeout (5 seconds for a state report
|
|
164
|
+
the daemon starts. Each hook has a timeout (5 seconds for a state report, 10 for the plan and the task list), so a stuck command
|
|
169
165
|
cannot hold up the agent. Plans are written to `~/.bondsmith/plans` readable only by you, and a symlink in
|
|
170
166
|
the way is replaced, not followed. A plan the agent changes later without going back through plan mode is
|
|
171
167
|
not seen; the hook's plan keeps winning over the folder's file until the next approval.
|
|
@@ -209,6 +205,33 @@ tmux only flags a bell when no client is attached to the session or the bell is
|
|
|
209
205
|
the one the attached client has open. A session whose terminal is open in a browser tab, even a hidden
|
|
210
206
|
one, counts as being looked at, so a bell in it is not flagged; use the hook report for that case.
|
|
211
207
|
|
|
208
|
+
## Adding an agent
|
|
209
|
+
|
|
210
|
+
The daemon splits what is the same for every agent from what is one agent's own.
|
|
211
|
+
|
|
212
|
+
| Part | Where | Agent-specific? |
|
|
213
|
+
| ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------- |
|
|
214
|
+
| What an agent reports: state, plan, task list, pull requests (the channels below) | `src/sessions/agent-reports/` | No |
|
|
215
|
+
| Turning reports into what the UI shows (`readSessionReport`: the plan and the task list for `getPlan`; attention state) | `agent-reports/`, `tmux/agent-state.ts` | No |
|
|
216
|
+
| Recognising an agent's command and launching it so it reports | `src/sessions/agents/<agent>/` | Yes |
|
|
217
|
+
| The list of agents | `AGENT_IDS` in `@bondsmith/local-agent-protocol`; `agents/registry.ts` | Add one line each |
|
|
218
|
+
|
|
219
|
+
An agent reports through these channels, and the daemon reads them without caring who wrote them:
|
|
220
|
+
|
|
221
|
+
| Report | Channel |
|
|
222
|
+
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
223
|
+
| State | tmux option `@bondsmith-agent-state` = `<working\|waiting\|done>:<epoch seconds>` on the session's pane; or ring the bell; or nothing (a pane gone quiet counts as waiting) |
|
|
224
|
+
| Plan | `~/.bondsmith/plans/<session id>.md`, or `.bondsmith/plan.md` in the session's folder, or tmux option `@bondsmith-plan` |
|
|
225
|
+
| Task list | `~/.bondsmith/plans/<session id>.tasks.jsonl`: one JSON `TaskEvent` per line, appended (`add`, `update`, `remove`, `replace`; see `agent-reports/task-list.ts`) |
|
|
226
|
+
| Pull requests | tmux option `@bondsmith-prs`, space-separated URLs |
|
|
227
|
+
|
|
228
|
+
To add an agent: add its id to `AGENT_IDS`, which makes `registry.ts` refuse to compile until it has an adapter. A new
|
|
229
|
+
`AgentAdapter` needs `matches(command)` and `prepareCommand(command)`. With nothing to set up use `plainAdapter(id)`,
|
|
230
|
+
as Codex and opencode do today (they report through the bell and the quiet-pane check). With hooks, put the agent's
|
|
231
|
+
settings and payload mapping in `agents/<agent>/` and build its hook commands from the helpers in
|
|
232
|
+
`agent-reports/hook-commands.ts` and `agent-reports/hook-scripts.ts`; the agent's file only says where in its
|
|
233
|
+
payload the plan, the task event and the shell command are. `agents/claude/` is the worked example.
|
|
234
|
+
|
|
212
235
|
## Production connectivity (current status)
|
|
213
236
|
|
|
214
237
|
`bondsmithd` connects outbound to the Bondsmith relay by default (`BONDSMITH_RELAY_ENABLED=true`,
|
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 = 20;
|
|
22
24
|
|
|
23
25
|
// ../../packages/local-agent-protocol/src/branch-name.ts
|
|
24
26
|
var MAX_BRANCH_NAME_LENGTH = 250;
|
|
@@ -185,6 +187,8 @@ var MAX_WIRE_MESSAGE_BYTES = 256 * 1024;
|
|
|
185
187
|
var MAX_DAEMON_REPLY_BYTES = 200 * 1024;
|
|
186
188
|
var MAX_RANGE_LINES = 500;
|
|
187
189
|
var MAX_PLAN_CHARS = 32 * 1024;
|
|
190
|
+
var MAX_PLAN_TASKS = 50;
|
|
191
|
+
var MAX_TASK_TEXT_CHARS = 120;
|
|
188
192
|
var MAX_TERMINAL_DIMENSION = 1e3;
|
|
189
193
|
var terminalSizeSchema = z3.object({
|
|
190
194
|
cols: z3.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
|
|
@@ -205,9 +209,9 @@ var localSessionSchema = z3.object({
|
|
|
205
209
|
worktreeOf: z3.string().nullable(),
|
|
206
210
|
// .catch(undefined): a state this version doesn't know must never drop the whole session message.
|
|
207
211
|
agentState: z3.enum(AGENT_STATES).nullable().optional().catch(void 0),
|
|
212
|
+
agent: z3.enum(AGENT_IDS).nullable().optional().catch(void 0),
|
|
208
213
|
worktreeSetupFailed: z3.boolean().optional().catch(void 0),
|
|
209
214
|
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
215
|
pullRequestUrls: z3.array(z3.string().max(300)).max(MAX_SESSION_PULL_REQUESTS).optional().catch(void 0)
|
|
212
216
|
});
|
|
213
217
|
var workspaceFileEntrySchema = z3.object({
|
|
@@ -388,6 +392,11 @@ var connectionAuthenticationFailureReasonSchema = z3.enum([
|
|
|
388
392
|
"key_agreement_failed"
|
|
389
393
|
]);
|
|
390
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
|
+
});
|
|
391
400
|
var daemonMessageSchema = z3.discriminatedUnion("type", [
|
|
392
401
|
z3.object({ type: z3.literal("session.list"), sessions: z3.array(localSessionSchema) }),
|
|
393
402
|
z3.object({
|
|
@@ -445,7 +454,8 @@ var daemonMessageSchema = z3.discriminatedUnion("type", [
|
|
|
445
454
|
type: z3.literal("workspace.plan"),
|
|
446
455
|
sessionId: z3.string(),
|
|
447
456
|
requestId: z3.string(),
|
|
448
|
-
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([])
|
|
449
459
|
}),
|
|
450
460
|
z3.object({
|
|
451
461
|
type: z3.literal("project.pathValidated"),
|
|
@@ -1043,7 +1053,7 @@ async function callTRPCQuery(baseUrl, procedure, input) {
|
|
|
1043
1053
|
// src/pairing/pairing.ts
|
|
1044
1054
|
var DEFAULT_POLL_INTERVAL_MS = 3e3;
|
|
1045
1055
|
function sleep(ms) {
|
|
1046
|
-
return new Promise((
|
|
1056
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1047
1057
|
}
|
|
1048
1058
|
async function ensurePaired(options) {
|
|
1049
1059
|
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
@@ -1181,8 +1191,8 @@ async function bindFirstController(params, response) {
|
|
|
1181
1191
|
}
|
|
1182
1192
|
|
|
1183
1193
|
// src/projects/validate-path.ts
|
|
1184
|
-
import { stat
|
|
1185
|
-
import { isAbsolute
|
|
1194
|
+
import { stat } from "fs/promises";
|
|
1195
|
+
import { isAbsolute, resolve } from "path";
|
|
1186
1196
|
|
|
1187
1197
|
// src/sessions/expand-home.ts
|
|
1188
1198
|
import { homedir } from "os";
|
|
@@ -1252,7 +1262,7 @@ function runGitDiffAllowingExitCodes(args, cwd, allowedExitCodes) {
|
|
|
1252
1262
|
var GIT_INPUT_TIMEOUT_MS = 3e4;
|
|
1253
1263
|
async function runGitWithInput(args, cwd, input, options = {}) {
|
|
1254
1264
|
const { allowedExitCodes = [], timeoutMs = GIT_INPUT_TIMEOUT_MS } = options;
|
|
1255
|
-
return new Promise((
|
|
1265
|
+
return new Promise((resolve3, reject) => {
|
|
1256
1266
|
const child = spawn2("git", [...SAFE_GIT_CONFIG, ...args], { cwd });
|
|
1257
1267
|
const stdout = [];
|
|
1258
1268
|
const stderr = [];
|
|
@@ -1276,7 +1286,7 @@ async function runGitWithInput(args, cwd, input, options = {}) {
|
|
|
1276
1286
|
"close",
|
|
1277
1287
|
(code) => settle(() => {
|
|
1278
1288
|
if (code === 0 || code !== null && allowedExitCodes.includes(code)) {
|
|
1279
|
-
|
|
1289
|
+
resolve3(Buffer.concat(stdout).toString("utf8"));
|
|
1280
1290
|
return;
|
|
1281
1291
|
}
|
|
1282
1292
|
const message = Buffer.concat(stderr).toString("utf8").trim();
|
|
@@ -1287,134 +1297,13 @@ async function runGitWithInput(args, cwd, input, options = {}) {
|
|
|
1287
1297
|
});
|
|
1288
1298
|
}
|
|
1289
1299
|
|
|
1290
|
-
// src/sessions/
|
|
1291
|
-
import { constants as constants2 } from "fs";
|
|
1292
|
-
import { open as open2 } from "fs/promises";
|
|
1300
|
+
// src/sessions/agent-reports/layout.ts
|
|
1293
1301
|
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
|
|
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";
|
|
1387
1306
|
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
1307
|
|
|
1419
1308
|
// src/sessions/tmux/client.ts
|
|
1420
1309
|
import { execFile as execFileCb2 } from "child_process";
|
|
@@ -1462,8 +1351,8 @@ async function runTmux(args, options) {
|
|
|
1462
1351
|
throw error2;
|
|
1463
1352
|
}
|
|
1464
1353
|
}
|
|
1465
|
-
async function sendKeysLiteral(tmuxSessionName,
|
|
1466
|
-
await runTmux(["send-keys", "-t", tmuxSessionName, "-l",
|
|
1354
|
+
async function sendKeysLiteral(tmuxSessionName, text2) {
|
|
1355
|
+
await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text2]);
|
|
1467
1356
|
}
|
|
1468
1357
|
async function setMouseMode(tmuxSessionName, enabled) {
|
|
1469
1358
|
await runTmux(["set-option", "-t", tmuxSessionName, "mouse", enabled ? "on" : "off"]);
|
|
@@ -1507,8 +1396,8 @@ var NO_SERVER_MESSAGE_PATTERNS = [
|
|
|
1507
1396
|
function isNoServerError(error2) {
|
|
1508
1397
|
if (typeof error2 !== "object" || error2 === null) return false;
|
|
1509
1398
|
const { message, stderr } = error2;
|
|
1510
|
-
const
|
|
1511
|
-
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));
|
|
1512
1401
|
}
|
|
1513
1402
|
|
|
1514
1403
|
// src/sessions/git/base.ts
|
|
@@ -1545,14 +1434,14 @@ async function resolveComparisonBase(cwd) {
|
|
|
1545
1434
|
}
|
|
1546
1435
|
|
|
1547
1436
|
// src/sessions/git/baseline.ts
|
|
1548
|
-
import { lstat
|
|
1549
|
-
import { join as
|
|
1437
|
+
import { lstat } from "fs/promises";
|
|
1438
|
+
import { join as join3 } from "path";
|
|
1550
1439
|
var SESSION_START_REF = "session start";
|
|
1551
1440
|
var MAX_PREEXISTING_FILES = 200;
|
|
1552
1441
|
var DELETED = "deleted";
|
|
1553
1442
|
async function fingerprintFile(cwd, path) {
|
|
1554
1443
|
try {
|
|
1555
|
-
const stats = await
|
|
1444
|
+
const stats = await lstat(join3(cwd, path));
|
|
1556
1445
|
return `${stats.size}:${Math.trunc(stats.mtimeMs)}`;
|
|
1557
1446
|
} catch {
|
|
1558
1447
|
return DELETED;
|
|
@@ -1876,23 +1765,23 @@ function gitFailure(error2, path) {
|
|
|
1876
1765
|
"git isn't installed, or isn't on the PATH of the terminal running bondsmithd."
|
|
1877
1766
|
);
|
|
1878
1767
|
}
|
|
1879
|
-
const
|
|
1880
|
-
if (
|
|
1768
|
+
const text2 = typeof stderr === "string" ? stderr : "";
|
|
1769
|
+
if (text2.includes("dubious ownership")) {
|
|
1881
1770
|
return new Error(
|
|
1882
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)}`
|
|
1883
1772
|
);
|
|
1884
1773
|
}
|
|
1885
|
-
const fatal =
|
|
1774
|
+
const fatal = text2.split("\n").find((line) => line.startsWith("fatal:"));
|
|
1886
1775
|
return new Error(
|
|
1887
1776
|
fatal ?? (error2 instanceof Error ? error2.message : "git failed to inspect that folder.")
|
|
1888
1777
|
);
|
|
1889
1778
|
}
|
|
1890
1779
|
async function validateProjectPath(input) {
|
|
1891
1780
|
const expanded = expandHome(input.trim());
|
|
1892
|
-
if (!
|
|
1781
|
+
if (!isAbsolute(expanded)) return { valid: false, reason: "relative_path" };
|
|
1893
1782
|
const path = resolve(expanded);
|
|
1894
1783
|
try {
|
|
1895
|
-
const stats = await
|
|
1784
|
+
const stats = await stat(path);
|
|
1896
1785
|
if (!stats.isDirectory()) return { valid: false, reason: "not_a_directory" };
|
|
1897
1786
|
} catch (error2) {
|
|
1898
1787
|
const reason = invalidReasonForFsError(error2);
|
|
@@ -1919,7 +1808,7 @@ import { randomBytes, verify as verify2 } from "crypto";
|
|
|
1919
1808
|
// src/security/trusted-controllers.ts
|
|
1920
1809
|
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
1921
1810
|
import { homedir as homedir3 } from "os";
|
|
1922
|
-
import { dirname, join as
|
|
1811
|
+
import { dirname, join as join4 } from "path";
|
|
1923
1812
|
import { z as z12 } from "zod";
|
|
1924
1813
|
var trustedControllerSchema = z12.object({
|
|
1925
1814
|
id: z12.string(),
|
|
@@ -1942,7 +1831,7 @@ var trustedControllersFileSchema = z12.object({
|
|
|
1942
1831
|
controllers: z12.array(trustedControllerSchema)
|
|
1943
1832
|
});
|
|
1944
1833
|
function defaultTrustedControllersPath() {
|
|
1945
|
-
return
|
|
1834
|
+
return join4(homedir3(), ".bondsmith", "trusted-controllers.json");
|
|
1946
1835
|
}
|
|
1947
1836
|
async function loadTrustedControllers(path = defaultTrustedControllersPath()) {
|
|
1948
1837
|
let raw;
|
|
@@ -2263,12 +2152,12 @@ var ConnectionAuthState = class {
|
|
|
2263
2152
|
};
|
|
2264
2153
|
|
|
2265
2154
|
// src/sessions/agents/installed-agents.ts
|
|
2266
|
-
import { accessSync, constants
|
|
2267
|
-
import { delimiter, join as
|
|
2268
|
-
var KNOWN_AGENTS =
|
|
2155
|
+
import { accessSync, constants, statSync } from "fs";
|
|
2156
|
+
import { delimiter, join as join5 } from "path";
|
|
2157
|
+
var KNOWN_AGENTS = AGENT_IDS;
|
|
2269
2158
|
function isExecutableFile(path) {
|
|
2270
2159
|
try {
|
|
2271
|
-
accessSync(path,
|
|
2160
|
+
accessSync(path, constants.X_OK);
|
|
2272
2161
|
return statSync(path).isFile();
|
|
2273
2162
|
} catch {
|
|
2274
2163
|
return false;
|
|
@@ -2277,12 +2166,12 @@ function isExecutableFile(path) {
|
|
|
2277
2166
|
function listInstalledAgents(env = process.env) {
|
|
2278
2167
|
const directories = (env.PATH ?? "").split(delimiter).filter((directory) => directory !== "");
|
|
2279
2168
|
return KNOWN_AGENTS.filter(
|
|
2280
|
-
(agent) => directories.some((directory) => isExecutableFile(
|
|
2169
|
+
(agent) => directories.some((directory) => isExecutableFile(join5(directory, agent)))
|
|
2281
2170
|
);
|
|
2282
2171
|
}
|
|
2283
2172
|
|
|
2284
2173
|
// src/version.ts
|
|
2285
|
-
var DAEMON_VERSION = true ? "0.1.
|
|
2174
|
+
var DAEMON_VERSION = true ? "0.1.16" : "dev";
|
|
2286
2175
|
var VERSION_FLAGS = ["--version", "-v"];
|
|
2287
2176
|
function versionLine() {
|
|
2288
2177
|
return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
|
|
@@ -2291,6 +2180,241 @@ function isVersionRequest(argv) {
|
|
|
2291
2180
|
return argv.some((arg) => VERSION_FLAGS.includes(arg));
|
|
2292
2181
|
}
|
|
2293
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
|
+
|
|
2294
2418
|
// src/security/verify-command.ts
|
|
2295
2419
|
import { verify as verify3 } from "crypto";
|
|
2296
2420
|
var ENVELOPE_MAX_AGE_MS = 6e4;
|
|
@@ -2427,8 +2551,8 @@ function createOutputPacer(options) {
|
|
|
2427
2551
|
|
|
2428
2552
|
// src/ws/sanitize-insert-text.ts
|
|
2429
2553
|
var DISALLOWED_CHARACTERS = /[\x00-\x08\x0b-\x1f\x7f-\x9f--]/g;
|
|
2430
|
-
function sanitizeInsertText(
|
|
2431
|
-
const withLineFeeds =
|
|
2554
|
+
function sanitizeInsertText(text2) {
|
|
2555
|
+
const withLineFeeds = text2.replace(/\r\n?/g, "\n");
|
|
2432
2556
|
const sanitized = withLineFeeds.replace(DISALLOWED_CHARACTERS, "");
|
|
2433
2557
|
return { text: sanitized, strippedCount: withLineFeeds.length - sanitized.length };
|
|
2434
2558
|
}
|
|
@@ -2762,14 +2886,14 @@ async function dispatch(conn, message, {
|
|
|
2762
2886
|
const session = getSessionOrError(sessionManager, conn, message.sessionId);
|
|
2763
2887
|
if (!session) return;
|
|
2764
2888
|
sessionManager.acknowledge(message.sessionId);
|
|
2765
|
-
const { text, strippedCount } = sanitizeInsertText(message.text);
|
|
2889
|
+
const { text: text2, strippedCount } = sanitizeInsertText(message.text);
|
|
2766
2890
|
if (strippedCount > 0) {
|
|
2767
2891
|
console.warn(`bondsmithd: stripped ${strippedCount} control characters from inserted text`);
|
|
2768
2892
|
}
|
|
2769
2893
|
try {
|
|
2770
2894
|
await sendKeysLiteral(
|
|
2771
2895
|
session.tmuxSessionName,
|
|
2772
|
-
`${BRACKETED_PASTE_START}${
|
|
2896
|
+
`${BRACKETED_PASTE_START}${text2}${BRACKETED_PASTE_END}`
|
|
2773
2897
|
);
|
|
2774
2898
|
} catch (err) {
|
|
2775
2899
|
conn.send(error(errorMessageOf(err, "failed to insert text")));
|
|
@@ -2880,12 +3004,13 @@ async function dispatch(conn, message, {
|
|
|
2880
3004
|
const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
|
|
2881
3005
|
if (!session) return;
|
|
2882
3006
|
try {
|
|
2883
|
-
const plan
|
|
3007
|
+
const { plan, tasks } = await readSessionReport(session);
|
|
2884
3008
|
conn.send({
|
|
2885
3009
|
type: "workspace.plan",
|
|
2886
3010
|
sessionId: message.sessionId,
|
|
2887
3011
|
requestId: message.requestId,
|
|
2888
|
-
plan
|
|
3012
|
+
plan,
|
|
3013
|
+
tasks
|
|
2889
3014
|
});
|
|
2890
3015
|
} catch (err) {
|
|
2891
3016
|
conn.send(error(errorMessageOf(err, "failed to read the session plan"), message.requestId));
|
|
@@ -3319,9 +3444,9 @@ function isMatchingKeyPair(publicKeyPem, privateKeyPem) {
|
|
|
3319
3444
|
return false;
|
|
3320
3445
|
}
|
|
3321
3446
|
}
|
|
3322
|
-
function extractPemBlock(
|
|
3447
|
+
function extractPemBlock(text2, label) {
|
|
3323
3448
|
const pattern = new RegExp(`-----BEGIN ${label}-----[\\s\\S]*?-----END ${label}-----\\n?`);
|
|
3324
|
-
return
|
|
3449
|
+
return text2.match(pattern)?.[0] ?? null;
|
|
3325
3450
|
}
|
|
3326
3451
|
|
|
3327
3452
|
// src/security/daemon-proof.ts
|
|
@@ -3460,8 +3585,8 @@ var PendingAuthorizationStore = class {
|
|
|
3460
3585
|
requestToken: randomUUID()
|
|
3461
3586
|
};
|
|
3462
3587
|
let settleFn;
|
|
3463
|
-
const resultPromise = new Promise((
|
|
3464
|
-
settleFn =
|
|
3588
|
+
const resultPromise = new Promise((resolve3) => {
|
|
3589
|
+
settleFn = resolve3;
|
|
3465
3590
|
});
|
|
3466
3591
|
const entry = {
|
|
3467
3592
|
authorization,
|
|
@@ -3572,16 +3697,16 @@ var PendingAuthorizationStore = class {
|
|
|
3572
3697
|
};
|
|
3573
3698
|
|
|
3574
3699
|
// src/security/revocation-sync.ts
|
|
3575
|
-
import { z as
|
|
3700
|
+
import { z as z14 } from "zod";
|
|
3576
3701
|
var POLL_INTERVAL_MS = 3e4;
|
|
3577
3702
|
var POLL_JITTER_MS = 6e3;
|
|
3578
3703
|
var POLL_REQUEST_TIMEOUT_MS = 1e4;
|
|
3579
3704
|
var BACKOFF_BASE_MS = 2e3;
|
|
3580
3705
|
var BACKOFF_MAX_MS = 2e4;
|
|
3581
3706
|
var MAX_CONSECUTIVE_FAILURE_BACKOFFS = 4;
|
|
3582
|
-
var checkRevocationStatusResponseSchema =
|
|
3583
|
-
pairingRevoked:
|
|
3584
|
-
revokedControllerPublicKeys:
|
|
3707
|
+
var checkRevocationStatusResponseSchema = z14.object({
|
|
3708
|
+
pairingRevoked: z14.boolean(),
|
|
3709
|
+
revokedControllerPublicKeys: z14.array(z14.string())
|
|
3585
3710
|
});
|
|
3586
3711
|
function jitteredInterval(deps) {
|
|
3587
3712
|
const interval = deps.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
@@ -3931,6 +4056,45 @@ ${new TmuxNotFoundError().message}
|
|
|
3931
4056
|
// src/sessions/session-manager.ts
|
|
3932
4057
|
import { v4 as uuidv4 } from "uuid";
|
|
3933
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
|
+
|
|
3934
4098
|
// src/sessions/git/worktree.ts
|
|
3935
4099
|
import { execFile as execFileCb3 } from "child_process";
|
|
3936
4100
|
import { promisify as promisify3 } from "util";
|
|
@@ -4029,22 +4193,15 @@ function decodeSkippedFiles(raw) {
|
|
|
4029
4193
|
|
|
4030
4194
|
// src/sessions/tmux/agent-state.ts
|
|
4031
4195
|
var AGENT_STATE_OPTION = "@bondsmith-agent-state";
|
|
4032
|
-
var HEADLINE_OPTION = "@bondsmith-headline";
|
|
4033
4196
|
var PULL_REQUESTS_OPTION = "@bondsmith-prs";
|
|
4034
4197
|
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}}`;
|
|
4198
|
+
var LIVE_SESSION_REPORTS_FORMAT = `#{session_name} #{${AGENT_STATE_OPTION}} #{window_bell_flag} #{${SETUP_FAILED_OPTION}} #{${SETUP_SKIPPED_OPTION}} #{${PULL_REQUESTS_OPTION}}`;
|
|
4037
4199
|
var AGENT_STATE_OUTPUT_GRACE_MS = 2e3;
|
|
4038
4200
|
var FUTURE_REPORT_ALLOWANCE_MS = 2e3;
|
|
4039
4201
|
var WORKING_SILENCE_MS = 12e4;
|
|
4040
4202
|
var SETTLE_QUIET_MS = 8e3;
|
|
4041
4203
|
var ACKNOWLEDGE_GRACE_MS = 3e3;
|
|
4042
4204
|
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
4205
|
function parsePullRequestUrls(raw) {
|
|
4049
4206
|
const urls = [...new Set(raw.match(PULL_REQUEST_URL) ?? [])];
|
|
4050
4207
|
return urls.slice(-MAX_SESSION_PULL_REQUESTS);
|
|
@@ -4061,8 +4218,7 @@ function parseLiveSessionReports(output) {
|
|
|
4061
4218
|
bell = "",
|
|
4062
4219
|
setupFailed = "",
|
|
4063
4220
|
setupSkipped = "",
|
|
4064
|
-
pullRequests = ""
|
|
4065
|
-
...headline
|
|
4221
|
+
pullRequests = ""
|
|
4066
4222
|
] = line.split(" ");
|
|
4067
4223
|
if (sessionName) {
|
|
4068
4224
|
reports.set(sessionName, {
|
|
@@ -4070,8 +4226,7 @@ function parseLiveSessionReports(output) {
|
|
|
4070
4226
|
bell: bell === "1",
|
|
4071
4227
|
setupFailed: setupFailed === "1",
|
|
4072
4228
|
setupSkipped: decodeSkippedFiles(setupSkipped),
|
|
4073
|
-
pullRequestUrls: parsePullRequestUrls(pullRequests)
|
|
4074
|
-
headline: cleanHeadline(headline.join(" "))
|
|
4229
|
+
pullRequestUrls: parsePullRequestUrls(pullRequests)
|
|
4075
4230
|
});
|
|
4076
4231
|
}
|
|
4077
4232
|
}
|
|
@@ -4122,56 +4277,67 @@ function resolveSessionAttention(report, lastActivityAt, { nowMs, acknowledgedAt
|
|
|
4122
4277
|
|
|
4123
4278
|
// src/sessions/tmux/create.ts
|
|
4124
4279
|
import { access } from "fs/promises";
|
|
4125
|
-
import { homedir as homedir7 } from "os";
|
|
4126
|
-
import { join as join11 } from "path";
|
|
4127
4280
|
|
|
4128
|
-
// 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
|
|
4129
4296
|
import { execFile as execFile4 } from "child_process";
|
|
4130
4297
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
4131
|
-
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";
|
|
4132
4299
|
import { homedir as homedir6 } from "os";
|
|
4133
|
-
import { join as
|
|
4300
|
+
import { join as join10 } from "path";
|
|
4134
4301
|
import { promisify as promisify4 } from "util";
|
|
4135
4302
|
|
|
4136
4303
|
// src/sessions/session-env.ts
|
|
4137
4304
|
var SESSION_ENV_VAR = "BONDSMITH_SESSION";
|
|
4138
4305
|
|
|
4139
|
-
// src/sessions/
|
|
4140
|
-
|
|
4141
|
-
var MIN_HEADLINE_CHARS = 4;
|
|
4142
|
-
function planHookScript(plansDir) {
|
|
4306
|
+
// src/sessions/agent-reports/hook-scripts.ts
|
|
4307
|
+
function storePlanScript(plansDir, planExpr) {
|
|
4143
4308
|
return [
|
|
4144
4309
|
`let d="";`,
|
|
4145
4310
|
`process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
|
|
4146
|
-
`const
|
|
4311
|
+
`const j=JSON.parse(d),p=${planExpr};if(typeof p!=="string"||!p)return;`,
|
|
4147
4312
|
`const fs=require("fs"),path=require("path"),dir=${JSON.stringify(plansDir)};`,
|
|
4148
4313
|
// basename keeps an odd session value from naming a file outside the plans folder.
|
|
4149
|
-
`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);`,
|
|
4150
4315
|
// Written aside and renamed: a reader never sees half a plan, and a symlink in the way is replaced, not followed.
|
|
4151
4316
|
`fs.mkdirSync(dir,{recursive:true,mode:0o700});`,
|
|
4152
4317
|
`fs.writeFileSync(tmp,p,{mode:0o600});fs.renameSync(tmp,path.join(dir,name))`,
|
|
4153
4318
|
`}catch{}})`
|
|
4154
4319
|
].join("");
|
|
4155
4320
|
}
|
|
4156
|
-
function
|
|
4321
|
+
function appendTaskEventScript(plansDir, eventExpr) {
|
|
4157
4322
|
return [
|
|
4158
4323
|
`let d="";`,
|
|
4159
4324
|
`process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
|
|
4160
|
-
`const
|
|
4161
|
-
`const
|
|
4162
|
-
`
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
`
|
|
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)}`,
|
|
4166
4331
|
`}catch{}})`
|
|
4167
4332
|
].join("");
|
|
4168
4333
|
}
|
|
4334
|
+
var PR_CREATE_MENTION = "gh pr create";
|
|
4169
4335
|
var PR_CREATE_COMMAND = /(?:^|[;&|\n(])\s*(?:\w+=\S*\s+)*gh\s+pr\s+create\b/;
|
|
4170
|
-
function
|
|
4336
|
+
function recordPullRequestScript(commandExpr, outputExpr) {
|
|
4171
4337
|
return [
|
|
4172
4338
|
`let d="";`,
|
|
4173
4339
|
`process.stdin.on("data",c=>d+=c).on("end",()=>{try{`,
|
|
4174
|
-
`const j=JSON.parse(d),c
|
|
4340
|
+
`const j=JSON.parse(d),c=${commandExpr},o=${outputExpr};`,
|
|
4175
4341
|
`if(typeof c!=="string"||typeof o!=="string"||!${PR_CREATE_COMMAND.toString()}.test(c))return;`,
|
|
4176
4342
|
`const m=o.match(${PULL_REQUEST_URL.toString()});if(!m)return;const u=m[m.length-1];`,
|
|
4177
4343
|
`const cp=require("child_process"),p=process.env.TMUX_PANE;`,
|
|
@@ -4182,29 +4348,49 @@ function pullRequestHookScript() {
|
|
|
4182
4348
|
].join("");
|
|
4183
4349
|
}
|
|
4184
4350
|
|
|
4185
|
-
// src/sessions/
|
|
4351
|
+
// src/sessions/agent-reports/hook-commands.ts
|
|
4186
4352
|
function shellQuote3(value) {
|
|
4187
4353
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
4188
4354
|
}
|
|
4189
4355
|
function nodeHookCommand(script, nodePath) {
|
|
4190
4356
|
return `[ -n "$${SESSION_ENV_VAR}" ] && ${shellQuote3(nodePath)} -e ${shellQuote3(script)}; exit 0`;
|
|
4191
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
|
|
4192
4382
|
function planHookCommand(nodePath = process.execPath, plansDir = HOOK_PLANS_DIR) {
|
|
4193
4383
|
return nodeHookCommand(planHookScript(plansDir), nodePath);
|
|
4194
4384
|
}
|
|
4195
|
-
function
|
|
4196
|
-
return nodeHookCommand(
|
|
4385
|
+
function taskHookCommand(nodePath = process.execPath, plansDir = HOOK_PLANS_DIR) {
|
|
4386
|
+
return nodeHookCommand(taskHookScript(plansDir), nodePath);
|
|
4197
4387
|
}
|
|
4198
4388
|
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`;
|
|
4389
|
+
return recordPullRequestCommand(pullRequestHookScript(), nodePath);
|
|
4204
4390
|
}
|
|
4205
4391
|
var STATE_HOOK_TIMEOUT_SECONDS = 5;
|
|
4206
4392
|
var PLAN_HOOK_TIMEOUT_SECONDS = 10;
|
|
4207
|
-
var
|
|
4393
|
+
var TASK_HOOK_TIMEOUT_SECONDS = 10;
|
|
4208
4394
|
var PULL_REQUEST_HOOK_TIMEOUT_SECONDS = 5;
|
|
4209
4395
|
function claudeSettings() {
|
|
4210
4396
|
const run = (command, timeout) => ({ type: "command", command, timeout });
|
|
@@ -4216,38 +4402,43 @@ function claudeSettings() {
|
|
|
4216
4402
|
matcher: "ExitPlanMode",
|
|
4217
4403
|
hooks: [run(planHookCommand(), PLAN_HOOK_TIMEOUT_SECONDS)]
|
|
4218
4404
|
},
|
|
4405
|
+
{
|
|
4406
|
+
matcher: TASK_TOOLS.join("|"),
|
|
4407
|
+
hooks: [run(taskHookCommand(), TASK_HOOK_TIMEOUT_SECONDS)]
|
|
4408
|
+
},
|
|
4219
4409
|
{
|
|
4220
4410
|
matcher: "Bash",
|
|
4221
4411
|
hooks: [run(pullRequestHookCommand(), PULL_REQUEST_HOOK_TIMEOUT_SECONDS)]
|
|
4222
4412
|
}
|
|
4223
4413
|
],
|
|
4224
|
-
// Working from the moment a prompt is sent
|
|
4225
|
-
UserPromptSubmit: [
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
}
|
|
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)] }
|
|
4229
4420
|
],
|
|
4230
|
-
PermissionRequest: [{ matcher: "*", hooks: [report("waiting")] }],
|
|
4231
4421
|
Stop: [{ hooks: [report("done")] }],
|
|
4232
4422
|
StopFailure: [{ hooks: [report("waiting")] }]
|
|
4233
4423
|
}
|
|
4234
4424
|
};
|
|
4235
4425
|
}
|
|
4236
4426
|
|
|
4237
|
-
// src/sessions/agents/claude
|
|
4427
|
+
// src/sessions/agents/claude/hooks.ts
|
|
4238
4428
|
var execFileAsync = promisify4(execFile4);
|
|
4239
|
-
var CLAUDE_HOOKS_DIR =
|
|
4429
|
+
var CLAUDE_HOOKS_DIR = join10(homedir6(), ".bondsmith", "hooks");
|
|
4240
4430
|
var AGENT_HOOKS_ENV_VAR = "BONDSMITH_AGENT_HOOKS";
|
|
4241
4431
|
var HOOKS_OFF_VALUES = /^(off|0|false|no)$/i;
|
|
4242
4432
|
var CLAUDE_BINARY = "claude";
|
|
4243
|
-
var LEADING_CLAUDE = new RegExp(
|
|
4433
|
+
var LEADING_CLAUDE = new RegExp(
|
|
4434
|
+
`^(\\s*(?:${ENV_ASSIGNMENT_WORD}\\s+)*)${CLAUDE_BINARY}(?=\\s|$)`
|
|
4435
|
+
);
|
|
4244
4436
|
var SETTINGS_FLAG = "--settings";
|
|
4245
4437
|
var SUPPORT_PROBE_TIMEOUT_MS = 1e4;
|
|
4246
4438
|
var SUPPORT_RETRY_AFTER_MS = 6e4;
|
|
4247
4439
|
function isPlainClaudeCommand(command) {
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
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}=`));
|
|
4251
4442
|
}
|
|
4252
4443
|
async function probeSettingsSupport() {
|
|
4253
4444
|
try {
|
|
@@ -4288,14 +4479,14 @@ async function prepareAgentCommand(command, {
|
|
|
4288
4479
|
if (HOOKS_OFF_VALUES.test(env[AGENT_HOOKS_ENV_VAR] ?? "")) return command;
|
|
4289
4480
|
if (!isPlainClaudeCommand(command)) return command;
|
|
4290
4481
|
if (!await supportsSettings()) return command;
|
|
4291
|
-
const file =
|
|
4482
|
+
const file = join10(hooksDir, "claude-settings.json");
|
|
4292
4483
|
const staging = `${file}.${randomUUID2()}.tmp`;
|
|
4293
4484
|
try {
|
|
4294
4485
|
await mkdir4(hooksDir, { recursive: true });
|
|
4295
4486
|
await writeFile4(staging, JSON.stringify(claudeSettings(), null, " "));
|
|
4296
4487
|
await rename4(staging, file);
|
|
4297
4488
|
} catch (error2) {
|
|
4298
|
-
await
|
|
4489
|
+
await rm5(staging, { force: true }).catch(() => void 0);
|
|
4299
4490
|
console.error(
|
|
4300
4491
|
"could not write the Claude Code hook settings; starting claude without them",
|
|
4301
4492
|
error2
|
|
@@ -4308,6 +4499,29 @@ async function prepareAgentCommand(command, {
|
|
|
4308
4499
|
);
|
|
4309
4500
|
}
|
|
4310
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
|
+
|
|
4311
4525
|
// src/sessions/git/start-point.ts
|
|
4312
4526
|
var WorktreeBaseBranchNotFoundError = class extends Error {
|
|
4313
4527
|
constructor(branch) {
|
|
@@ -4331,15 +4545,26 @@ async function resolveWorktreeStartPoint(sourceCwd, baseBranch) {
|
|
|
4331
4545
|
throw new WorktreeBaseBranchNotFoundError(baseBranch);
|
|
4332
4546
|
}
|
|
4333
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
|
+
|
|
4334
4559
|
// src/sessions/worktree-setup/copy-files.ts
|
|
4335
4560
|
import { constants as constants4 } from "fs";
|
|
4336
|
-
import { copyFile, glob, lstat as
|
|
4337
|
-
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";
|
|
4338
4563
|
var GIT_EXIT_NONE_IGNORED = 1;
|
|
4339
4564
|
var TRACKED_CHECK_BATCH = 200;
|
|
4340
4565
|
function isInside(root, path) {
|
|
4341
4566
|
const rel = relative(root, path);
|
|
4342
|
-
return rel !== ".." && !rel.startsWith(`..${sep2}`) && !
|
|
4567
|
+
return rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute4(rel);
|
|
4343
4568
|
}
|
|
4344
4569
|
async function ignoredPaths(worktree, paths) {
|
|
4345
4570
|
if (paths.length === 0) return /* @__PURE__ */ new Set();
|
|
@@ -4368,13 +4593,13 @@ async function trackedPaths(worktree, paths) {
|
|
|
4368
4593
|
}
|
|
4369
4594
|
return tracked;
|
|
4370
4595
|
}
|
|
4371
|
-
async function copyFilesIntoWorktree(sourceRoot,
|
|
4596
|
+
async function copyFilesIntoWorktree(sourceRoot, worktreeRoot2, globs) {
|
|
4372
4597
|
const result = { copied: [], failed: 0, skipped: [] };
|
|
4373
4598
|
const patterns = globs.filter(isSafeCopyGlob);
|
|
4374
4599
|
if (patterns.length === 0) return result;
|
|
4375
4600
|
try {
|
|
4376
|
-
const source = await
|
|
4377
|
-
const worktree = await
|
|
4601
|
+
const source = await realpath3(sourceRoot);
|
|
4602
|
+
const worktree = await realpath3(worktreeRoot2);
|
|
4378
4603
|
const candidates = [];
|
|
4379
4604
|
for await (const match of glob(patterns, { cwd: source })) {
|
|
4380
4605
|
if (match.split(sep2).includes(".git")) continue;
|
|
@@ -4417,11 +4642,11 @@ async function copyFilesIntoWorktree(sourceRoot, worktreeRoot, globs) {
|
|
|
4417
4642
|
return result;
|
|
4418
4643
|
}
|
|
4419
4644
|
async function isCopyCandidate(source, worktree, match) {
|
|
4420
|
-
const from =
|
|
4421
|
-
if (!(await
|
|
4422
|
-
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;
|
|
4423
4648
|
try {
|
|
4424
|
-
await
|
|
4649
|
+
await lstat4(join12(worktree, match));
|
|
4425
4650
|
return false;
|
|
4426
4651
|
} catch (error2) {
|
|
4427
4652
|
if (error2.code === "ENOENT") return true;
|
|
@@ -4429,11 +4654,11 @@ async function isCopyCandidate(source, worktree, match) {
|
|
|
4429
4654
|
}
|
|
4430
4655
|
}
|
|
4431
4656
|
async function copyOne(source, worktree, match) {
|
|
4432
|
-
const to =
|
|
4433
|
-
await mkdir5(
|
|
4434
|
-
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;
|
|
4435
4660
|
try {
|
|
4436
|
-
await copyFile(
|
|
4661
|
+
await copyFile(join12(source, match), to, constants4.COPYFILE_EXCL);
|
|
4437
4662
|
} catch (error2) {
|
|
4438
4663
|
if (error2.code === "EEXIST") return false;
|
|
4439
4664
|
throw error2;
|
|
@@ -4448,9 +4673,6 @@ var InvalidCwdError = class extends Error {
|
|
|
4448
4673
|
this.name = "InvalidCwdError";
|
|
4449
4674
|
}
|
|
4450
4675
|
};
|
|
4451
|
-
function worktreePathFor(sessionId) {
|
|
4452
|
-
return join11(homedir7(), ".bondsmith", "worktrees", sessionId);
|
|
4453
|
-
}
|
|
4454
4676
|
async function createTmuxSession(input) {
|
|
4455
4677
|
const sourceCwd = expandHome(input.cwd);
|
|
4456
4678
|
let cwd = sourceCwd;
|
|
@@ -4481,7 +4703,7 @@ async function createTmuxSession(input) {
|
|
|
4481
4703
|
const tmuxSessionName = tmuxSessionNameFor(input.id);
|
|
4482
4704
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4483
4705
|
const baseline = await captureBaseline(cwd, { freshCheckout: worktreeOf !== null });
|
|
4484
|
-
const agentCommand = await
|
|
4706
|
+
const agentCommand = await prepareCommand(input.command);
|
|
4485
4707
|
const setupCommand = worktreeOf ? input.worktreeSetup?.setupCommand?.trim() : void 0;
|
|
4486
4708
|
const launchCommand = setupCommand ? wrapCommandWithSetup({ setupCommand, command: agentCommand, tmuxSessionName }) : agentCommand;
|
|
4487
4709
|
await runTmux(
|
|
@@ -4530,6 +4752,7 @@ async function createTmuxSession(input) {
|
|
|
4530
4752
|
id: input.id,
|
|
4531
4753
|
title: input.title,
|
|
4532
4754
|
command: input.command,
|
|
4755
|
+
agent: agentIdFor(input.command),
|
|
4533
4756
|
cwd,
|
|
4534
4757
|
tmuxSessionName,
|
|
4535
4758
|
status: "running",
|
|
@@ -4566,6 +4789,7 @@ function parseTmuxSessions(output) {
|
|
|
4566
4789
|
id,
|
|
4567
4790
|
title,
|
|
4568
4791
|
command,
|
|
4792
|
+
agent: agentIdFor(command),
|
|
4569
4793
|
cwd,
|
|
4570
4794
|
tmuxSessionName: sessionName,
|
|
4571
4795
|
status: "running",
|
|
@@ -4649,6 +4873,9 @@ var SessionManager = class {
|
|
|
4649
4873
|
for (const [id, baseline] of discovered.baselines) {
|
|
4650
4874
|
this.baselines.set(id, baseline);
|
|
4651
4875
|
}
|
|
4876
|
+
sweepOrphanedHookPlanFiles(new Set(this.sessions.keys())).catch(
|
|
4877
|
+
(error2) => console.error("Failed to sweep stored plans of ended sessions", error2)
|
|
4878
|
+
);
|
|
4652
4879
|
}
|
|
4653
4880
|
list() {
|
|
4654
4881
|
return Array.from(this.sessions.values());
|
|
@@ -4744,7 +4971,6 @@ ${error2.message}
|
|
|
4744
4971
|
agentState: resolveSessionAttention(report, enriched.lastActivityAt, {
|
|
4745
4972
|
acknowledgedAtMs: this.acknowledgements.get(session.id) ?? null
|
|
4746
4973
|
}),
|
|
4747
|
-
headline: report?.headline ?? null,
|
|
4748
4974
|
...report?.pullRequestUrls?.length && { pullRequestUrls: report.pullRequestUrls },
|
|
4749
4975
|
...report?.setupFailed && { worktreeSetupFailed: true },
|
|
4750
4976
|
...report?.setupSkipped?.length && { worktreeSetupSkipped: report.setupSkipped }
|
|
@@ -4767,12 +4993,15 @@ ${error2.message}
|
|
|
4767
4993
|
this.sessions.delete(session.id);
|
|
4768
4994
|
this.baselines.delete(session.id);
|
|
4769
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
|
+
);
|
|
4770
4999
|
this.emitUpdated(updated);
|
|
4771
5000
|
return updated;
|
|
4772
5001
|
}
|
|
4773
5002
|
};
|
|
4774
5003
|
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) &&
|
|
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 ?? []);
|
|
4776
5005
|
}
|
|
4777
5006
|
|
|
4778
5007
|
// src/ws/server.ts
|
|
@@ -4939,10 +5168,10 @@ if (isVersionRequest(process.argv.slice(2))) {
|
|
|
4939
5168
|
process.exit(0);
|
|
4940
5169
|
}
|
|
4941
5170
|
function loadRepoRootEnv() {
|
|
4942
|
-
const repoRoot =
|
|
4943
|
-
if (!existsSync(
|
|
4944
|
-
const envLocal =
|
|
4945
|
-
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");
|
|
4946
5175
|
loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
|
|
4947
5176
|
}
|
|
4948
5177
|
loadRepoRootEnv();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bondsmith/bondsmithd",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Local daemon for Bondsmith agent sessions — runs on the user's own machine, pairs with a Bondsmith account, and exposes tmux-backed terminal sessions to the Bondsmith web app over a signed WebSocket protocol.",
|
|
6
6
|
"homepage": "https://bondsmith.dev",
|