@bondsmith/bondsmithd 0.1.5 → 0.1.7
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 +24 -0
- package/dist/index.js +250 -42
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,30 @@ daemon". Reload the page first; if that doesn't fix it, update the daemon as abo
|
|
|
66
66
|
- Keeps tmux itself as the only source of truth — no database. Restarting the daemon rediscovers
|
|
67
67
|
running sessions from tmux.
|
|
68
68
|
|
|
69
|
+
## Agent state
|
|
70
|
+
|
|
71
|
+
An agent can tell Bondsmith what it is doing by running this from inside its session's pane, usually
|
|
72
|
+
from one of its hooks. `$TMUX_PANE` is set by tmux in every pane, so the command targets the session
|
|
73
|
+
that pane belongs to:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
tmux set-option -t "$TMUX_PANE" -q @bondsmith-agent-state "waiting:$(date +%s)"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The value is `<state>:<epoch seconds>`, where state is `working`, `waiting` (needs the user) or `done`.
|
|
80
|
+
The daemon reads it on its 5-second tick and shows it on the session. Anything else is ignored, as is
|
|
81
|
+
a report dated in the future. A `waiting` or `done` report is dropped once the pane writes output more
|
|
82
|
+
than 2 seconds after it, since that means the agent moved on; a `working` report is not.
|
|
83
|
+
|
|
84
|
+
An agent with no hook can still get attention by ringing the terminal bell (`printf '\a'`). A bell
|
|
85
|
+
rung in a session nobody has looked at marks it as waiting, whatever its last report said, until tmux
|
|
86
|
+
clears the flag when someone attaches to the session. Agents that can ring the bell for approvals or
|
|
87
|
+
a finished turn work this way with nothing to install.
|
|
88
|
+
|
|
89
|
+
tmux only flags a bell when no client is attached to the session or the bell is in a window other than
|
|
90
|
+
the one the attached client has open. A session whose terminal is open in a browser tab, even a hidden
|
|
91
|
+
one, counts as being looked at, so a bell in it is not flagged; use the hook report for that case.
|
|
92
|
+
|
|
69
93
|
## Production connectivity (current status)
|
|
70
94
|
|
|
71
95
|
`bondsmithd` connects outbound to the Bondsmith relay by default (`BONDSMITH_RELAY_ENABLED=true`,
|
package/dist/index.js
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
import { createServer } from "http";
|
|
5
5
|
import { existsSync } from "fs";
|
|
6
6
|
import { hostname as hostname2 } from "os";
|
|
7
|
-
import { dirname as dirname4, join as
|
|
7
|
+
import { dirname as dirname4, join as join7 } 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
|
+
var AGENT_STATES = ["working", "waiting", "done"];
|
|
12
13
|
|
|
13
14
|
// ../../packages/local-agent-protocol/src/messages.ts
|
|
14
15
|
var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
|
|
@@ -76,7 +77,9 @@ var localSessionSchema = z2.object({
|
|
|
76
77
|
repositoryFullName: z2.string().nullable(),
|
|
77
78
|
lastActivityAt: z2.string().nullable(),
|
|
78
79
|
changedFileCount: z2.number().int().nonnegative().nullable(),
|
|
79
|
-
worktreeOf: z2.string().nullable()
|
|
80
|
+
worktreeOf: z2.string().nullable(),
|
|
81
|
+
// .catch(undefined): a state this version doesn't know must never drop the whole session message.
|
|
82
|
+
agentState: z2.enum(AGENT_STATES).nullable().optional().catch(void 0)
|
|
80
83
|
});
|
|
81
84
|
var workspaceFileEntrySchema = z2.object({
|
|
82
85
|
path: z2.string(),
|
|
@@ -951,7 +954,7 @@ import { sign } from "crypto";
|
|
|
951
954
|
import WebSocket from "ws";
|
|
952
955
|
|
|
953
956
|
// src/version.ts
|
|
954
|
-
var DAEMON_VERSION = true ? "0.1.
|
|
957
|
+
var DAEMON_VERSION = true ? "0.1.7" : "dev";
|
|
955
958
|
var VERSION_FLAGS = ["--version", "-v"];
|
|
956
959
|
function versionLine() {
|
|
957
960
|
return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
|
|
@@ -1012,9 +1015,9 @@ async function sendKeysLiteral(tmuxSessionName, text) {
|
|
|
1012
1015
|
async function capturePane(tmuxSessionName) {
|
|
1013
1016
|
return runTmux(["capture-pane", "-t", tmuxSessionName, "-p", "-e"]);
|
|
1014
1017
|
}
|
|
1015
|
-
async function
|
|
1018
|
+
async function getSessionActivity(tmuxSessionName) {
|
|
1016
1019
|
try {
|
|
1017
|
-
const out = (await runTmux(["display-message", "-p", "-t", tmuxSessionName, "#{
|
|
1020
|
+
const out = (await runTmux(["display-message", "-p", "-t", tmuxSessionName, "#{window_activity}"])).trim();
|
|
1018
1021
|
const seconds = Number(out);
|
|
1019
1022
|
if (!out || Number.isNaN(seconds)) return null;
|
|
1020
1023
|
return new Date(seconds * 1e3).toISOString();
|
|
@@ -1577,7 +1580,10 @@ async function dispatch(conn, message, {
|
|
|
1577
1580
|
const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
|
|
1578
1581
|
if (!session) return;
|
|
1579
1582
|
try {
|
|
1580
|
-
const status = await getWorkspaceStatus2(
|
|
1583
|
+
const status = await getWorkspaceStatus2(
|
|
1584
|
+
session.cwd,
|
|
1585
|
+
sessionManager.getBaseline(session.id)
|
|
1586
|
+
);
|
|
1581
1587
|
conn.send({
|
|
1582
1588
|
type: "workspace.status",
|
|
1583
1589
|
sessionId: message.sessionId,
|
|
@@ -1593,7 +1599,12 @@ async function dispatch(conn, message, {
|
|
|
1593
1599
|
const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
|
|
1594
1600
|
if (!session) return;
|
|
1595
1601
|
try {
|
|
1596
|
-
const diff = await getWorkspaceDiff2(
|
|
1602
|
+
const diff = await getWorkspaceDiff2(
|
|
1603
|
+
session.cwd,
|
|
1604
|
+
message.path,
|
|
1605
|
+
message.staged,
|
|
1606
|
+
sessionManager.getBaseline(session.id)
|
|
1607
|
+
);
|
|
1597
1608
|
conn.send({
|
|
1598
1609
|
type: "workspace.diff",
|
|
1599
1610
|
sessionId: message.sessionId,
|
|
@@ -2541,6 +2552,10 @@ function presentPendingAuthorizationOnTerminal(store) {
|
|
|
2541
2552
|
};
|
|
2542
2553
|
}
|
|
2543
2554
|
|
|
2555
|
+
// src/sessions/git/baseline.ts
|
|
2556
|
+
import { lstat } from "fs/promises";
|
|
2557
|
+
import { join as join5 } from "path";
|
|
2558
|
+
|
|
2544
2559
|
// src/sessions/git/base.ts
|
|
2545
2560
|
var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
2546
2561
|
var DEFAULT_BRANCH_CANDIDATES = ["origin/main", "origin/master", "main", "master"];
|
|
@@ -2574,6 +2589,107 @@ async function resolveComparisonBase(cwd) {
|
|
|
2574
2589
|
return mergeBase ? { sha: mergeBase, ref: defaultRef } : { sha: "HEAD", ref: null };
|
|
2575
2590
|
}
|
|
2576
2591
|
|
|
2592
|
+
// src/sessions/git/baseline.ts
|
|
2593
|
+
var SESSION_START_REF = "session start";
|
|
2594
|
+
var MAX_PREEXISTING_FILES = 200;
|
|
2595
|
+
var DELETED = "deleted";
|
|
2596
|
+
async function fingerprintFile(cwd, path) {
|
|
2597
|
+
try {
|
|
2598
|
+
const stats = await lstat(join5(cwd, path));
|
|
2599
|
+
return `${stats.size}:${Math.trunc(stats.mtimeMs)}`;
|
|
2600
|
+
} catch {
|
|
2601
|
+
return DELETED;
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
async function headSha(cwd) {
|
|
2605
|
+
try {
|
|
2606
|
+
return (await runGit(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], cwd)).trim() || null;
|
|
2607
|
+
} catch {
|
|
2608
|
+
return null;
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
async function listChangedPaths(cwd, baseSha) {
|
|
2612
|
+
const [tracked, untracked] = await Promise.all([
|
|
2613
|
+
runGitDiff(["--relative", "--name-only", "-z", baseSha], cwd),
|
|
2614
|
+
runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
|
|
2615
|
+
]);
|
|
2616
|
+
const paths = [...tracked.split("\0"), ...untracked.split("\0")].filter((p) => p.length > 0);
|
|
2617
|
+
return [...new Set(paths)];
|
|
2618
|
+
}
|
|
2619
|
+
async function captureBaseline(cwd, options) {
|
|
2620
|
+
try {
|
|
2621
|
+
await runGit(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
2622
|
+
} catch {
|
|
2623
|
+
return null;
|
|
2624
|
+
}
|
|
2625
|
+
try {
|
|
2626
|
+
const baseSha = await headSha(cwd);
|
|
2627
|
+
const preexisting = /* @__PURE__ */ new Map();
|
|
2628
|
+
if (!options.freshCheckout) {
|
|
2629
|
+
const changed = await listChangedPaths(cwd, baseSha ?? EMPTY_TREE_SHA);
|
|
2630
|
+
for (const path of changed.slice(0, MAX_PREEXISTING_FILES)) {
|
|
2631
|
+
preexisting.set(path, await fingerprintFile(cwd, path));
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
return { baseSha, preexisting };
|
|
2635
|
+
} catch (error2) {
|
|
2636
|
+
console.error("Failed to take a session baseline; comparing against the default branch", {
|
|
2637
|
+
cwd,
|
|
2638
|
+
error: error2
|
|
2639
|
+
});
|
|
2640
|
+
return null;
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
async function commitExists(cwd, sha) {
|
|
2644
|
+
try {
|
|
2645
|
+
await runGit(["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], cwd);
|
|
2646
|
+
return true;
|
|
2647
|
+
} catch {
|
|
2648
|
+
return false;
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
async function resolveBaseFor(cwd, baseline) {
|
|
2652
|
+
if (!baseline) return resolveComparisonBase(cwd);
|
|
2653
|
+
if (baseline.baseSha !== null && !await commitExists(cwd, baseline.baseSha)) {
|
|
2654
|
+
return resolveComparisonBase(cwd);
|
|
2655
|
+
}
|
|
2656
|
+
return { sha: baseline.baseSha ?? EMPTY_TREE_SHA, ref: SESSION_START_REF };
|
|
2657
|
+
}
|
|
2658
|
+
async function dropPreexisting(cwd, entries, baseline) {
|
|
2659
|
+
if (baseline.preexisting.size === 0) return entries;
|
|
2660
|
+
const untouched = await Promise.all(
|
|
2661
|
+
entries.map(async (entry) => {
|
|
2662
|
+
const before = baseline.preexisting.get(entry.path);
|
|
2663
|
+
return before !== void 0 && before === await fingerprintFile(cwd, entry.path);
|
|
2664
|
+
})
|
|
2665
|
+
);
|
|
2666
|
+
return entries.filter((_, index) => !untouched[index]);
|
|
2667
|
+
}
|
|
2668
|
+
function encodeBaseline(baseline) {
|
|
2669
|
+
const serialized = {
|
|
2670
|
+
v: 1,
|
|
2671
|
+
base: baseline.baseSha,
|
|
2672
|
+
files: [...baseline.preexisting]
|
|
2673
|
+
};
|
|
2674
|
+
return Buffer.from(JSON.stringify(serialized), "utf8").toString("base64url");
|
|
2675
|
+
}
|
|
2676
|
+
function isSerializedBaseline(value) {
|
|
2677
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2678
|
+
const candidate = value;
|
|
2679
|
+
return candidate.v === 1 && (candidate.base === null || typeof candidate.base === "string") && Array.isArray(candidate.files) && candidate.files.every(
|
|
2680
|
+
(file) => Array.isArray(file) && file.length === 2 && typeof file[0] === "string" && typeof file[1] === "string"
|
|
2681
|
+
);
|
|
2682
|
+
}
|
|
2683
|
+
function decodeBaseline(encoded) {
|
|
2684
|
+
try {
|
|
2685
|
+
const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
2686
|
+
if (!isSerializedBaseline(parsed)) return null;
|
|
2687
|
+
return { baseSha: parsed.base, preexisting: new Map(parsed.files) };
|
|
2688
|
+
} catch {
|
|
2689
|
+
return null;
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2577
2693
|
// src/sessions/git/status.ts
|
|
2578
2694
|
async function getCurrentBranch(cwd) {
|
|
2579
2695
|
try {
|
|
@@ -2669,15 +2785,15 @@ function parseNameStatus(output) {
|
|
|
2669
2785
|
}
|
|
2670
2786
|
return changed;
|
|
2671
2787
|
}
|
|
2672
|
-
async function getWorkspaceStatus(cwd) {
|
|
2788
|
+
async function getWorkspaceStatus(cwd, baseline) {
|
|
2673
2789
|
const branch = await getCurrentBranch(cwd);
|
|
2674
2790
|
if (branch === null && !await isGitRepo(cwd)) {
|
|
2675
2791
|
return { isGitRepo: false, branch: null, files: [] };
|
|
2676
2792
|
}
|
|
2677
|
-
const base = await
|
|
2793
|
+
const base = await resolveBaseFor(cwd, baseline);
|
|
2678
2794
|
const [nameStatusRaw, numstatRaw, untrackedRaw] = await Promise.all([
|
|
2679
|
-
runGitDiff(["--name-status", "-z", "-M", base.sha], cwd),
|
|
2680
|
-
runGitDiff(["--numstat", "-z", "-M", base.sha], cwd),
|
|
2795
|
+
runGitDiff(["--relative", "--name-status", "-z", "-M", base.sha], cwd),
|
|
2796
|
+
runGitDiff(["--relative", "--numstat", "-z", "-M", base.sha], cwd),
|
|
2681
2797
|
runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
|
|
2682
2798
|
]);
|
|
2683
2799
|
const numstat = parseNumstat(numstatRaw);
|
|
@@ -2701,14 +2817,20 @@ async function getWorkspaceStatus(cwd) {
|
|
|
2701
2817
|
deletions: null,
|
|
2702
2818
|
binary: false
|
|
2703
2819
|
}));
|
|
2704
|
-
|
|
2820
|
+
const files = [...tracked, ...untracked];
|
|
2821
|
+
return {
|
|
2822
|
+
isGitRepo: true,
|
|
2823
|
+
branch,
|
|
2824
|
+
baseRef: base.ref,
|
|
2825
|
+
files: baseline ? await dropPreexisting(cwd, files, baseline) : files
|
|
2826
|
+
};
|
|
2705
2827
|
}
|
|
2706
|
-
async function enrichSessionWithGitMetadata(session) {
|
|
2828
|
+
async function enrichSessionWithGitMetadata(session, baseline) {
|
|
2707
2829
|
const [branch, repositoryFullName, status, lastActivityAt] = await Promise.all([
|
|
2708
2830
|
getCurrentBranch(session.cwd),
|
|
2709
2831
|
getRepositoryFullName(session.cwd),
|
|
2710
|
-
getWorkspaceStatus(session.cwd),
|
|
2711
|
-
|
|
2832
|
+
getWorkspaceStatus(session.cwd, baseline),
|
|
2833
|
+
getSessionActivity(session.tmuxSessionName)
|
|
2712
2834
|
]);
|
|
2713
2835
|
const changedFileCount = status.isGitRepo ? new Set(status.files.map((f) => f.path)).size : null;
|
|
2714
2836
|
return {
|
|
@@ -2722,7 +2844,7 @@ async function enrichSessionWithGitMetadata(session) {
|
|
|
2722
2844
|
|
|
2723
2845
|
// src/sessions/git/diff.ts
|
|
2724
2846
|
async function withRenameSource(cwd, baseSha, path) {
|
|
2725
|
-
const nameStatus = await runGitDiff(["--name-status", "-z", "-M", baseSha], cwd);
|
|
2847
|
+
const nameStatus = await runGitDiff(["--relative", "--name-status", "-z", "-M", baseSha], cwd);
|
|
2726
2848
|
const previousPath = parseNameStatus(nameStatus).find((c) => c.path === path)?.previousPath;
|
|
2727
2849
|
return previousPath === void 0 ? [path] : [previousPath, path];
|
|
2728
2850
|
}
|
|
@@ -2730,15 +2852,18 @@ async function isUntracked(cwd, path) {
|
|
|
2730
2852
|
const out = await runGit(["ls-files", "--others", "--exclude-standard", "--", path], cwd);
|
|
2731
2853
|
return out.trim() !== "";
|
|
2732
2854
|
}
|
|
2733
|
-
async function getWorkspaceDiff(cwd, path, staged) {
|
|
2734
|
-
const base = await
|
|
2855
|
+
async function getWorkspaceDiff(cwd, path, staged, baseline) {
|
|
2856
|
+
const base = await resolveBaseFor(cwd, baseline);
|
|
2735
2857
|
const pathspec = await withRenameSource(cwd, base.sha, path);
|
|
2736
|
-
const numstat = await runGitDiff(
|
|
2858
|
+
const numstat = await runGitDiff(
|
|
2859
|
+
["--relative", "--numstat", "-M", base.sha, "--", ...pathspec],
|
|
2860
|
+
cwd
|
|
2861
|
+
);
|
|
2737
2862
|
const binary = /^(-\t-\t)/.test(numstat.trim());
|
|
2738
2863
|
if (binary) {
|
|
2739
2864
|
return { path, staged, patch: null, binary: true };
|
|
2740
2865
|
}
|
|
2741
|
-
let patch = await runGitDiff(["-M", base.sha, "--", ...pathspec], cwd);
|
|
2866
|
+
let patch = await runGitDiff(["--relative", "-M", base.sha, "--", ...pathspec], cwd);
|
|
2742
2867
|
if (patch.trim() === "" && await isUntracked(cwd, path)) {
|
|
2743
2868
|
patch = await runGitDiffAllowingExitCodes(["--no-index", "--", "/dev/null", path], cwd, [
|
|
2744
2869
|
1
|
|
@@ -2932,10 +3057,42 @@ async function removeWorktree(sourceCwd, worktreePath) {
|
|
|
2932
3057
|
}
|
|
2933
3058
|
}
|
|
2934
3059
|
|
|
3060
|
+
// src/sessions/tmux/agent-state.ts
|
|
3061
|
+
var LIVE_SESSION_REPORTS_FORMAT = "#{session_name} #{@bondsmith-agent-state} #{window_bell_flag}";
|
|
3062
|
+
var AGENT_STATE_OUTPUT_GRACE_MS = 2e3;
|
|
3063
|
+
var FUTURE_REPORT_ALLOWANCE_MS = 2e3;
|
|
3064
|
+
var SETTLED_STATES = /* @__PURE__ */ new Set(["waiting", "done"]);
|
|
3065
|
+
function isAgentState(value) {
|
|
3066
|
+
return AGENT_STATES.includes(value);
|
|
3067
|
+
}
|
|
3068
|
+
function parseLiveSessionReports(output) {
|
|
3069
|
+
const reports = /* @__PURE__ */ new Map();
|
|
3070
|
+
for (const line of output.split("\n")) {
|
|
3071
|
+
const [sessionName, agentState = "", bell = ""] = line.split(" ");
|
|
3072
|
+
if (sessionName) reports.set(sessionName, { agentState, bell: bell === "1" });
|
|
3073
|
+
}
|
|
3074
|
+
return reports;
|
|
3075
|
+
}
|
|
3076
|
+
function resolveAgentState(raw, lastActivityAt, nowMs = Date.now()) {
|
|
3077
|
+
if (!raw) return null;
|
|
3078
|
+
const [state, epoch] = raw.split(":");
|
|
3079
|
+
if (!state || !isAgentState(state) || !epoch || !/^\d+$/.test(epoch)) return null;
|
|
3080
|
+
const reportedAtMs = Number(epoch) * 1e3;
|
|
3081
|
+
if (reportedAtMs > nowMs + FUTURE_REPORT_ALLOWANCE_MS) return null;
|
|
3082
|
+
if (SETTLED_STATES.has(state) && lastActivityAt) {
|
|
3083
|
+
if (Date.parse(lastActivityAt) > reportedAtMs + AGENT_STATE_OUTPUT_GRACE_MS) return null;
|
|
3084
|
+
}
|
|
3085
|
+
return state;
|
|
3086
|
+
}
|
|
3087
|
+
function resolveSessionAttention(report, lastActivityAt, nowMs) {
|
|
3088
|
+
if (report?.bell) return "waiting";
|
|
3089
|
+
return resolveAgentState(report?.agentState, lastActivityAt, nowMs);
|
|
3090
|
+
}
|
|
3091
|
+
|
|
2935
3092
|
// src/sessions/tmux/create.ts
|
|
2936
3093
|
import { access } from "fs/promises";
|
|
2937
3094
|
import { homedir as homedir5 } from "os";
|
|
2938
|
-
import { join as
|
|
3095
|
+
import { join as join6 } from "path";
|
|
2939
3096
|
var InvalidCwdError = class extends Error {
|
|
2940
3097
|
constructor(cwd) {
|
|
2941
3098
|
super(`working directory does not exist: ${cwd}`);
|
|
@@ -2943,7 +3100,7 @@ var InvalidCwdError = class extends Error {
|
|
|
2943
3100
|
}
|
|
2944
3101
|
};
|
|
2945
3102
|
function worktreePathFor(sessionId) {
|
|
2946
|
-
return
|
|
3103
|
+
return join6(homedir5(), ".bondsmith", "worktrees", sessionId);
|
|
2947
3104
|
}
|
|
2948
3105
|
async function createTmuxSession(input) {
|
|
2949
3106
|
const sourceCwd = expandHome(input.cwd);
|
|
@@ -2962,6 +3119,7 @@ async function createTmuxSession(input) {
|
|
|
2962
3119
|
}
|
|
2963
3120
|
const tmuxSessionName = tmuxSessionNameFor(input.id);
|
|
2964
3121
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3122
|
+
const baseline = await captureBaseline(cwd, { freshCheckout: worktreeOf !== null });
|
|
2965
3123
|
await runTmux(["new-session", "-d", "-s", tmuxSessionName, "-c", cwd, input.command], {
|
|
2966
3124
|
env: filterSpawnEnv(process.env)
|
|
2967
3125
|
});
|
|
@@ -2973,6 +3131,15 @@ async function createTmuxSession(input) {
|
|
|
2973
3131
|
if (worktreeOf) {
|
|
2974
3132
|
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-worktree-of", worktreeOf]);
|
|
2975
3133
|
}
|
|
3134
|
+
if (baseline) {
|
|
3135
|
+
await runTmux([
|
|
3136
|
+
"set-option",
|
|
3137
|
+
"-t",
|
|
3138
|
+
tmuxSessionName,
|
|
3139
|
+
"@bondsmith-baseline",
|
|
3140
|
+
encodeBaseline(baseline)
|
|
3141
|
+
]);
|
|
3142
|
+
}
|
|
2976
3143
|
const session = {
|
|
2977
3144
|
id: input.id,
|
|
2978
3145
|
title: input.title,
|
|
@@ -2987,7 +3154,7 @@ async function createTmuxSession(input) {
|
|
|
2987
3154
|
changedFileCount: null,
|
|
2988
3155
|
worktreeOf
|
|
2989
3156
|
};
|
|
2990
|
-
return enrichSessionWithGitMetadata(session);
|
|
3157
|
+
return { session: await enrichSessionWithGitMetadata(session, baseline), baseline };
|
|
2991
3158
|
}
|
|
2992
3159
|
async function stopTmuxSession(tmuxSessionName) {
|
|
2993
3160
|
await runTmux(["kill-session", "-t", tmuxSessionName]);
|
|
@@ -2995,6 +3162,7 @@ async function stopTmuxSession(tmuxSessionName) {
|
|
|
2995
3162
|
|
|
2996
3163
|
// src/sessions/tmux/parse.ts
|
|
2997
3164
|
var FIELD_COUNT = 7;
|
|
3165
|
+
var BASELINE_FIELD_INDEX = 7;
|
|
2998
3166
|
function parseTmuxSessions(output) {
|
|
2999
3167
|
const sessions = [];
|
|
3000
3168
|
for (const line of output.split("\n")) {
|
|
@@ -3024,6 +3192,19 @@ function parseTmuxSessions(output) {
|
|
|
3024
3192
|
}
|
|
3025
3193
|
return sessions;
|
|
3026
3194
|
}
|
|
3195
|
+
function parseSessionBaselines(output) {
|
|
3196
|
+
const baselines = /* @__PURE__ */ new Map();
|
|
3197
|
+
for (const line of output.split("\n")) {
|
|
3198
|
+
const fields = line.split(" ");
|
|
3199
|
+
const sessionName = fields[0];
|
|
3200
|
+
const id = fields[1];
|
|
3201
|
+
const encoded = fields[BASELINE_FIELD_INDEX];
|
|
3202
|
+
if (!sessionName || !isBondsmithTmuxSessionName(sessionName) || !id || !encoded) continue;
|
|
3203
|
+
const baseline = decodeBaseline(encoded);
|
|
3204
|
+
if (baseline) baselines.set(id, baseline);
|
|
3205
|
+
}
|
|
3206
|
+
return baselines;
|
|
3207
|
+
}
|
|
3027
3208
|
|
|
3028
3209
|
// src/sessions/tmux/discover.ts
|
|
3029
3210
|
var LIST_FORMAT = [
|
|
@@ -3033,15 +3214,21 @@ var LIST_FORMAT = [
|
|
|
3033
3214
|
"#{@bondsmith-command}",
|
|
3034
3215
|
"#{@bondsmith-cwd}",
|
|
3035
3216
|
"#{@bondsmith-started-at}",
|
|
3036
|
-
"#{@bondsmith-worktree-of}"
|
|
3217
|
+
"#{@bondsmith-worktree-of}",
|
|
3218
|
+
"#{@bondsmith-baseline}"
|
|
3037
3219
|
].join(" ");
|
|
3038
3220
|
async function discoverSessions() {
|
|
3039
3221
|
try {
|
|
3040
3222
|
const output = await listTmuxSessions(LIST_FORMAT);
|
|
3041
|
-
const
|
|
3042
|
-
|
|
3223
|
+
const baselines = parseSessionBaselines(output);
|
|
3224
|
+
const sessions = await Promise.all(
|
|
3225
|
+
parseTmuxSessions(output).map(
|
|
3226
|
+
(session) => enrichSessionWithGitMetadata(session, baselines.get(session.id))
|
|
3227
|
+
)
|
|
3228
|
+
);
|
|
3229
|
+
return { sessions, baselines };
|
|
3043
3230
|
} catch (error2) {
|
|
3044
|
-
if (error2 instanceof TmuxNoServerRunningError) return [];
|
|
3231
|
+
if (error2 instanceof TmuxNoServerRunningError) return { sessions: [], baselines: /* @__PURE__ */ new Map() };
|
|
3045
3232
|
if (error2 instanceof TmuxNotFoundError) {
|
|
3046
3233
|
console.error(`
|
|
3047
3234
|
${error2.message}
|
|
@@ -3055,13 +3242,19 @@ ${error2.message}
|
|
|
3055
3242
|
// src/sessions/session-manager.ts
|
|
3056
3243
|
var SessionManager = class {
|
|
3057
3244
|
sessions = /* @__PURE__ */ new Map();
|
|
3245
|
+
// What each session's folder looked like when it started, so its file list shows only its own changes.
|
|
3246
|
+
baselines = /* @__PURE__ */ new Map();
|
|
3058
3247
|
listeners = /* @__PURE__ */ new Set();
|
|
3059
3248
|
async initialize() {
|
|
3060
3249
|
const discovered = await discoverSessions();
|
|
3061
3250
|
this.sessions.clear();
|
|
3062
|
-
|
|
3251
|
+
this.baselines.clear();
|
|
3252
|
+
for (const session of discovered.sessions) {
|
|
3063
3253
|
this.sessions.set(session.id, session);
|
|
3064
3254
|
}
|
|
3255
|
+
for (const [id, baseline] of discovered.baselines) {
|
|
3256
|
+
this.baselines.set(id, baseline);
|
|
3257
|
+
}
|
|
3065
3258
|
}
|
|
3066
3259
|
list() {
|
|
3067
3260
|
return Array.from(this.sessions.values());
|
|
@@ -3069,6 +3262,10 @@ var SessionManager = class {
|
|
|
3069
3262
|
get(sessionId) {
|
|
3070
3263
|
return this.sessions.get(sessionId);
|
|
3071
3264
|
}
|
|
3265
|
+
/** Undefined for a session started before baselines existed, which compares against the default branch. */
|
|
3266
|
+
getBaseline(sessionId) {
|
|
3267
|
+
return this.baselines.get(sessionId);
|
|
3268
|
+
}
|
|
3072
3269
|
onSessionUpdated(listener) {
|
|
3073
3270
|
this.listeners.add(listener);
|
|
3074
3271
|
return () => this.listeners.delete(listener);
|
|
@@ -3078,8 +3275,9 @@ var SessionManager = class {
|
|
|
3078
3275
|
}
|
|
3079
3276
|
async create(input) {
|
|
3080
3277
|
const id = uuidv4();
|
|
3081
|
-
const session = await createTmuxSession({ id, ...input });
|
|
3278
|
+
const { session, baseline } = await createTmuxSession({ id, ...input });
|
|
3082
3279
|
this.sessions.set(id, session);
|
|
3280
|
+
if (baseline) this.baselines.set(id, baseline);
|
|
3083
3281
|
return session;
|
|
3084
3282
|
}
|
|
3085
3283
|
async stop(sessionId) {
|
|
@@ -3114,13 +3312,12 @@ var SessionManager = class {
|
|
|
3114
3312
|
* at create/discover — otherwise the associated-work lookup (open PR whose
|
|
3115
3313
|
* head = session.branch) stays pinned to a branch the developer already
|
|
3116
3314
|
* left. `session.updated` is only emitted when something actually changed,
|
|
3117
|
-
* so an idle session doesn't broadcast every tick.
|
|
3315
|
+
* so an idle session doesn't broadcast every tick. The same tick re-reads each session's agent report.
|
|
3118
3316
|
*/
|
|
3119
3317
|
async reconcile() {
|
|
3120
|
-
let
|
|
3318
|
+
let agentReports = /* @__PURE__ */ new Map();
|
|
3121
3319
|
try {
|
|
3122
|
-
|
|
3123
|
-
liveSessionNames = new Set(output.split("\n").filter((name) => name.length > 0));
|
|
3320
|
+
agentReports = parseLiveSessionReports(await listTmuxSessions(LIVE_SESSION_REPORTS_FORMAT));
|
|
3124
3321
|
} catch (error2) {
|
|
3125
3322
|
if (error2 instanceof TmuxNotFoundError) console.error(`
|
|
3126
3323
|
${error2.message}
|
|
@@ -3134,13 +3331,23 @@ ${error2.message}
|
|
|
3134
3331
|
}
|
|
3135
3332
|
await Promise.all(
|
|
3136
3333
|
Array.from(this.sessions.values()).map(async (session) => {
|
|
3137
|
-
if (!
|
|
3334
|
+
if (!agentReports.has(session.tmuxSessionName)) {
|
|
3138
3335
|
this.removeAsExited(session);
|
|
3139
3336
|
return;
|
|
3140
3337
|
}
|
|
3141
3338
|
try {
|
|
3142
|
-
const
|
|
3143
|
-
|
|
3339
|
+
const enriched = await enrichSessionWithGitMetadata(
|
|
3340
|
+
session,
|
|
3341
|
+
this.baselines.get(session.id)
|
|
3342
|
+
);
|
|
3343
|
+
const updated = {
|
|
3344
|
+
...enriched,
|
|
3345
|
+
agentState: resolveSessionAttention(
|
|
3346
|
+
agentReports.get(session.tmuxSessionName),
|
|
3347
|
+
enriched.lastActivityAt
|
|
3348
|
+
)
|
|
3349
|
+
};
|
|
3350
|
+
if (!snapshotEquals(session, updated)) {
|
|
3144
3351
|
this.sessions.set(session.id, updated);
|
|
3145
3352
|
this.emitUpdated(updated);
|
|
3146
3353
|
}
|
|
@@ -3156,12 +3363,13 @@ ${error2.message}
|
|
|
3156
3363
|
removeAsExited(session) {
|
|
3157
3364
|
const updated = { ...session, status: "exited" };
|
|
3158
3365
|
this.sessions.delete(session.id);
|
|
3366
|
+
this.baselines.delete(session.id);
|
|
3159
3367
|
this.emitUpdated(updated);
|
|
3160
3368
|
return updated;
|
|
3161
3369
|
}
|
|
3162
3370
|
};
|
|
3163
|
-
function
|
|
3164
|
-
return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt;
|
|
3371
|
+
function snapshotEquals(a, b) {
|
|
3372
|
+
return a.branch === b.branch && a.repositoryFullName === b.repositoryFullName && a.changedFileCount === b.changedFileCount && a.lastActivityAt === b.lastActivityAt && (a.agentState ?? null) === (b.agentState ?? null);
|
|
3165
3373
|
}
|
|
3166
3374
|
|
|
3167
3375
|
// src/ws/server.ts
|
|
@@ -3316,10 +3524,10 @@ if (isVersionRequest(process.argv.slice(2))) {
|
|
|
3316
3524
|
process.exit(0);
|
|
3317
3525
|
}
|
|
3318
3526
|
function loadRepoRootEnv() {
|
|
3319
|
-
const repoRoot =
|
|
3320
|
-
if (!existsSync(
|
|
3321
|
-
const envLocal =
|
|
3322
|
-
const envDefault =
|
|
3527
|
+
const repoRoot = join7(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
3528
|
+
if (!existsSync(join7(repoRoot, "pnpm-workspace.yaml"))) return;
|
|
3529
|
+
const envLocal = join7(repoRoot, ".env.local");
|
|
3530
|
+
const envDefault = join7(repoRoot, ".env");
|
|
3323
3531
|
loadEnv({ path: existsSync(envLocal) ? envLocal : envDefault });
|
|
3324
3532
|
}
|
|
3325
3533
|
loadRepoRootEnv();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bondsmith/bondsmithd",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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",
|