@bondsmith/bondsmithd 0.1.6 → 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 +55 -14
- 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
|
@@ -9,6 +9,7 @@ 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();
|
|
@@ -2827,7 +2830,7 @@ async function enrichSessionWithGitMetadata(session, baseline) {
|
|
|
2827
2830
|
getCurrentBranch(session.cwd),
|
|
2828
2831
|
getRepositoryFullName(session.cwd),
|
|
2829
2832
|
getWorkspaceStatus(session.cwd, baseline),
|
|
2830
|
-
|
|
2833
|
+
getSessionActivity(session.tmuxSessionName)
|
|
2831
2834
|
]);
|
|
2832
2835
|
const changedFileCount = status.isGitRepo ? new Set(status.files.map((f) => f.path)).size : null;
|
|
2833
2836
|
return {
|
|
@@ -3054,6 +3057,38 @@ async function removeWorktree(sourceCwd, worktreePath) {
|
|
|
3054
3057
|
}
|
|
3055
3058
|
}
|
|
3056
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
|
+
|
|
3057
3092
|
// src/sessions/tmux/create.ts
|
|
3058
3093
|
import { access } from "fs/promises";
|
|
3059
3094
|
import { homedir as homedir5 } from "os";
|
|
@@ -3277,13 +3312,12 @@ var SessionManager = class {
|
|
|
3277
3312
|
* at create/discover — otherwise the associated-work lookup (open PR whose
|
|
3278
3313
|
* head = session.branch) stays pinned to a branch the developer already
|
|
3279
3314
|
* left. `session.updated` is only emitted when something actually changed,
|
|
3280
|
-
* 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.
|
|
3281
3316
|
*/
|
|
3282
3317
|
async reconcile() {
|
|
3283
|
-
let
|
|
3318
|
+
let agentReports = /* @__PURE__ */ new Map();
|
|
3284
3319
|
try {
|
|
3285
|
-
|
|
3286
|
-
liveSessionNames = new Set(output.split("\n").filter((name) => name.length > 0));
|
|
3320
|
+
agentReports = parseLiveSessionReports(await listTmuxSessions(LIVE_SESSION_REPORTS_FORMAT));
|
|
3287
3321
|
} catch (error2) {
|
|
3288
3322
|
if (error2 instanceof TmuxNotFoundError) console.error(`
|
|
3289
3323
|
${error2.message}
|
|
@@ -3297,16 +3331,23 @@ ${error2.message}
|
|
|
3297
3331
|
}
|
|
3298
3332
|
await Promise.all(
|
|
3299
3333
|
Array.from(this.sessions.values()).map(async (session) => {
|
|
3300
|
-
if (!
|
|
3334
|
+
if (!agentReports.has(session.tmuxSessionName)) {
|
|
3301
3335
|
this.removeAsExited(session);
|
|
3302
3336
|
return;
|
|
3303
3337
|
}
|
|
3304
3338
|
try {
|
|
3305
|
-
const
|
|
3339
|
+
const enriched = await enrichSessionWithGitMetadata(
|
|
3306
3340
|
session,
|
|
3307
3341
|
this.baselines.get(session.id)
|
|
3308
3342
|
);
|
|
3309
|
-
|
|
3343
|
+
const updated = {
|
|
3344
|
+
...enriched,
|
|
3345
|
+
agentState: resolveSessionAttention(
|
|
3346
|
+
agentReports.get(session.tmuxSessionName),
|
|
3347
|
+
enriched.lastActivityAt
|
|
3348
|
+
)
|
|
3349
|
+
};
|
|
3350
|
+
if (!snapshotEquals(session, updated)) {
|
|
3310
3351
|
this.sessions.set(session.id, updated);
|
|
3311
3352
|
this.emitUpdated(updated);
|
|
3312
3353
|
}
|
|
@@ -3327,8 +3368,8 @@ ${error2.message}
|
|
|
3327
3368
|
return updated;
|
|
3328
3369
|
}
|
|
3329
3370
|
};
|
|
3330
|
-
function
|
|
3331
|
-
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);
|
|
3332
3373
|
}
|
|
3333
3374
|
|
|
3334
3375
|
// src/ws/server.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bondsmith/bondsmithd",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.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",
|