benignswarm 0.9.0 → 0.10.0
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/dist/client.js +50 -23
- package/dist/commands/agent.js +5 -1
- package/dist/commands/goal.js +55 -0
- package/dist/commands/hook.js +30 -15
- package/dist/commands/join.js +25 -2
- package/dist/commands/watch.js +28 -4
- package/dist/heartbeat.js +96 -0
- package/dist/help.js +6 -0
- package/dist/index.js +2 -0
- package/dist/lock.js +53 -0
- package/dist/model.js +36 -0
- package/dist/output.js +2 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -13,8 +13,13 @@ export class Client {
|
|
|
13
13
|
this.auto = auto;
|
|
14
14
|
this.agentName = agentName;
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* presence: send the X-Bschat-Auto header, which refreshes the agent's automatic status and
|
|
18
|
+
* marks it seen. True for commands the agent runs itself; false for polls made on its behalf
|
|
19
|
+
* by the hook or the watcher, which say nothing about whether the agent is alive.
|
|
20
|
+
*/
|
|
21
|
+
static forAgent(c, model, opts = {}) {
|
|
22
|
+
return new Client(c.url, c.key, opts.presence === false ? null : { ...collectAuto({ model, machine: c.machine }) }, c.agent);
|
|
18
23
|
}
|
|
19
24
|
static forAdmin(c) {
|
|
20
25
|
return new Client(c.url, c.key, null);
|
|
@@ -28,31 +33,50 @@ export class Client {
|
|
|
28
33
|
h["x-bschat-auto"] = JSON.stringify(this.auto);
|
|
29
34
|
return h;
|
|
30
35
|
}
|
|
31
|
-
|
|
36
|
+
/**
|
|
37
|
+
* opts.retries: how many times to retry on a network failure or a 5xx. Only safe for calls
|
|
38
|
+
* that are idempotent on the server (posts carry a client_id for exactly this reason).
|
|
39
|
+
*/
|
|
40
|
+
async call(method, path, body, opts = {}) {
|
|
32
41
|
const headers = this.headers();
|
|
33
42
|
if (body !== undefined)
|
|
34
43
|
headers["content-type"] = "application/json";
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
res
|
|
44
|
+
const retries = opts.retries ?? 0;
|
|
45
|
+
for (let attempt = 0;; attempt++) {
|
|
46
|
+
let res;
|
|
47
|
+
try {
|
|
48
|
+
res = await fetch(this.url + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
if (attempt < retries) {
|
|
52
|
+
await backoff(attempt);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
throw new CliError("network", `could not reach ${this.url}: ${e.message}`);
|
|
56
|
+
}
|
|
57
|
+
noteServerVersions(res.headers, this.agentName);
|
|
58
|
+
const text = await res.text();
|
|
59
|
+
let json;
|
|
60
|
+
try {
|
|
61
|
+
json = JSON.parse(text);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new CliError("bad_response", `${method} ${path} returned non-JSON (${res.status}): ${text.slice(0, 200)}`);
|
|
65
|
+
}
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
if (res.status >= 500 && attempt < retries) {
|
|
68
|
+
process.stderr.write(`bschat: ${method} ${path} failed (${res.status}${json?.reference ? `, reference ${json.reference}` : ""}); retrying\n`);
|
|
69
|
+
await backoff(attempt);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const err = json;
|
|
73
|
+
const hint = res.status >= 500
|
|
74
|
+
? `This is a platform failure, not something to fix on your side. Do not retry in a loop. Report it once: bschat feedback "<what you were doing>${err.reference ? ` ref ${err.reference}` : ""}" and carry on with other work.`
|
|
75
|
+
: undefined;
|
|
76
|
+
throw new CliError(err.error ?? `http_${res.status}`, err.message ?? text, hint);
|
|
77
|
+
}
|
|
78
|
+
return json;
|
|
38
79
|
}
|
|
39
|
-
catch (e) {
|
|
40
|
-
throw new CliError("network", `could not reach ${this.url}: ${e.message}`);
|
|
41
|
-
}
|
|
42
|
-
noteServerVersions(res.headers, this.agentName);
|
|
43
|
-
const text = await res.text();
|
|
44
|
-
let json;
|
|
45
|
-
try {
|
|
46
|
-
json = JSON.parse(text);
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
throw new CliError("bad_response", `${method} ${path} returned non-JSON (${res.status}): ${text.slice(0, 200)}`);
|
|
50
|
-
}
|
|
51
|
-
if (!res.ok) {
|
|
52
|
-
const err = json;
|
|
53
|
-
throw new CliError(err.error ?? `http_${res.status}`, err.message ?? text);
|
|
54
|
-
}
|
|
55
|
-
return json;
|
|
56
80
|
}
|
|
57
81
|
wsUrl(path) {
|
|
58
82
|
return this.url.replace(/^http/, "ws") + path;
|
|
@@ -63,3 +87,6 @@ export class Client {
|
|
|
63
87
|
return ws;
|
|
64
88
|
}
|
|
65
89
|
}
|
|
90
|
+
function backoff(attempt) {
|
|
91
|
+
return new Promise((r) => setTimeout(r, Math.min(8000, 500 * 2 ** attempt)));
|
|
92
|
+
}
|
package/dist/commands/agent.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { bool, need, str } from "../args.js";
|
|
4
5
|
import { Client } from "../client.js";
|
|
@@ -142,7 +143,10 @@ export async function send(p, out) {
|
|
|
142
143
|
if (replyTo !== undefined && !/^\d+$/.test(replyTo))
|
|
143
144
|
throw new CliError("usage", "--reply-to takes the seq number of the message you are answering");
|
|
144
145
|
const project = projectOf(p, creds);
|
|
145
|
-
|
|
146
|
+
// One client id per post, reused across retries: a timeout or 5xx after the archive write
|
|
147
|
+
// returns the message that landed instead of posting it twice.
|
|
148
|
+
const clientId = randomUUID();
|
|
149
|
+
const r = await client.call("POST", `/projects/${project}/channels/${channel}/messages`, { body, reply_to: replyTo === undefined ? null : Number(replyTo), client_id: clientId }, { retries: 3 });
|
|
146
150
|
{
|
|
147
151
|
// Our own post must not count as unread for us; advance the cursor if we were caught up.
|
|
148
152
|
const st = readState(agentRef(creds));
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { str } from "../args.js";
|
|
2
|
+
import { Client } from "../client.js";
|
|
3
|
+
import { agentRef, loadAgent, resolveAgentName } from "../config.js";
|
|
4
|
+
import { CliError } from "../errors.js";
|
|
5
|
+
import { heartbeatNow, HARNESS_GOAL_STATES } from "../heartbeat.js";
|
|
6
|
+
import { readState, writeState } from "../state.js";
|
|
7
|
+
/**
|
|
8
|
+
* Harness goal self-report, for harnesses whose /goal state lives where no file on this machine
|
|
9
|
+
* shows it (Codex app threads keep it in the cloud; DeepSeek exposes it through get_goal). Claude
|
|
10
|
+
* Code needs none of this: the hook reads the goal from the transcript.
|
|
11
|
+
*
|
|
12
|
+
* bschat goal set "<the /goal text>" [--state pursuing|paused|stalled|achieved|abandoned]
|
|
13
|
+
* bschat goal achieved | paused | stalled | abandoned (same text, new state)
|
|
14
|
+
* bschat goal clear
|
|
15
|
+
* bschat goal (what the server has for you)
|
|
16
|
+
*/
|
|
17
|
+
export async function goal(p, out) {
|
|
18
|
+
const sub = p.positionals[1];
|
|
19
|
+
const name = resolveAgentName(str(p.flags, "as"));
|
|
20
|
+
const creds = loadAgent(name);
|
|
21
|
+
const client = Client.forAgent(creds, undefined, { presence: false });
|
|
22
|
+
const ref = agentRef(creds);
|
|
23
|
+
if (!sub) {
|
|
24
|
+
const path = creds.project ? `/projects/${creds.project}/agents/status` : "/account/agents/status";
|
|
25
|
+
const r = await client.call("GET", path);
|
|
26
|
+
const mine = (creds.project ? r.statuses : r.account.statuses).find((s) => s.agent_name === creds.agent);
|
|
27
|
+
const g = mine?.harness?.goal;
|
|
28
|
+
out.print(g ? `harness goal (${g.state}): ${g.text}\nstatus goal (${mine.goal_source ?? "agent"}): ${mine.goal ?? "-"}` : `no harness goal reported for ${ref}`, { harness_goal: g ?? null, goal: mine?.goal ?? null, goal_source: mine?.goal_source ?? null });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
let next;
|
|
32
|
+
if (sub === "clear")
|
|
33
|
+
next = null;
|
|
34
|
+
else if (sub === "set") {
|
|
35
|
+
const text = p.positionals.slice(2).join(" ").trim();
|
|
36
|
+
if (!text)
|
|
37
|
+
throw new CliError("usage", 'goal set "<the goal text exactly as given to /goal>" [--state pursuing]');
|
|
38
|
+
const state = str(p.flags, "state") ?? "pursuing";
|
|
39
|
+
if (!HARNESS_GOAL_STATES.includes(state))
|
|
40
|
+
throw new CliError("usage", `--state must be one of ${HARNESS_GOAL_STATES.join(", ")}`);
|
|
41
|
+
next = { text, state: state };
|
|
42
|
+
}
|
|
43
|
+
else if (HARNESS_GOAL_STATES.includes(sub)) {
|
|
44
|
+
const last = readState(ref).harness_goal;
|
|
45
|
+
if (!last)
|
|
46
|
+
throw new CliError("no_goal", `no goal recorded on this machine to mark ${sub}`, 'Report it first: bschat goal set "<text>"');
|
|
47
|
+
next = { text: last.text, state: sub };
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
throw new CliError("usage", `goal [set <text> [--state s] | ${HARNESS_GOAL_STATES.join(" | ")} | clear]`);
|
|
51
|
+
}
|
|
52
|
+
const r = await heartbeatNow(client, "agent-report", next);
|
|
53
|
+
writeState(ref, { ...readState(ref), harness_goal: next ?? undefined });
|
|
54
|
+
out.print(next ? `reported harness goal (${next.state}): ${next.text}` : "reported: no harness goal", { status: r });
|
|
55
|
+
}
|
package/dist/commands/hook.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { str } from "../args.js";
|
|
3
3
|
import { Client } from "../client.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { loadAgent, resolveAgentName, resolveModel } from "../config.js";
|
|
5
|
+
import { heartbeatNow } from "../heartbeat.js";
|
|
6
6
|
import { fmtMessage } from "../output.js";
|
|
7
7
|
import { readState, writeState } from "../state.js";
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import { goalFromClaudeTranscript, modelFromClaudeTranscript } from "../model.js";
|
|
9
|
+
const HEARTBEAT_MS = 60_000;
|
|
10
10
|
/** Reads the harness's hook payload from stdin without blocking when there is none. */
|
|
11
11
|
function readStdinJson() {
|
|
12
12
|
try {
|
|
@@ -39,7 +39,8 @@ export async function gatherUpdates(name, opts) {
|
|
|
39
39
|
return null;
|
|
40
40
|
let messages;
|
|
41
41
|
let items = [];
|
|
42
|
-
|
|
42
|
+
// A poll on the agent's behalf: no presence header, so last_seen keeps meaning "the agent acted".
|
|
43
|
+
const client = Client.forAgent(creds, resolveModel(opts.model, process.env, process.cwd(), name), { presence: false });
|
|
43
44
|
try {
|
|
44
45
|
const qs = new URLSearchParams({ limit: "20" });
|
|
45
46
|
if (state.inbox_after)
|
|
@@ -149,9 +150,13 @@ export async function gatherUpdates(name, opts) {
|
|
|
149
150
|
export async function hook(p) {
|
|
150
151
|
const harness = p.positionals[1] ?? "plain";
|
|
151
152
|
const event = str(p.flags, "event") ?? (harness === "claude-code" ? "UserPromptSubmit" : "");
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
let name;
|
|
154
|
+
try {
|
|
155
|
+
name = resolveAgentName(str(p.flags, "as"));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return; // not configured here, or ambiguous: silent by design; the agent's own commands will say so
|
|
159
|
+
}
|
|
155
160
|
// Model observation: hook input (SessionStart `model`, PostModelSwitch `to_model`) or the transcript tail.
|
|
156
161
|
const input = readStdinJson();
|
|
157
162
|
if (input) {
|
|
@@ -162,6 +167,23 @@ export async function hook(p) {
|
|
|
162
167
|
}
|
|
163
168
|
if (event === "PostModelSwitch")
|
|
164
169
|
return;
|
|
170
|
+
// The hook firing is proof the harness is working; tell the server, at most once a minute.
|
|
171
|
+
// The session goal (/goal) rides along from the transcript, and a change goes out at once.
|
|
172
|
+
if (harness === "claude-code") {
|
|
173
|
+
const st = readState(name);
|
|
174
|
+
const observedGoal = typeof input?.transcript_path === "string" ? goalFromClaudeTranscript(input.transcript_path) : undefined;
|
|
175
|
+
const goalChanged = observedGoal !== undefined && (JSON.stringify(observedGoal) !== JSON.stringify(st.harness_goal ?? null) || !st.harness_goal_reported);
|
|
176
|
+
if (goalChanged || Date.now() - (st.heartbeat_at ?? 0) > HEARTBEAT_MS) {
|
|
177
|
+
try {
|
|
178
|
+
await heartbeatNow(Client.forAgent(loadAgent(name), undefined, { presence: false }), "claude-code-hook", goalChanged ? observedGoal : undefined);
|
|
179
|
+
const cur = readState(name);
|
|
180
|
+
writeState(name, { ...cur, heartbeat_at: Date.now(), ...(goalChanged ? { harness_goal: observedGoal ?? undefined, harness_goal_reported: true } : {}) });
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
process.stderr.write(`bschat: heartbeat failed: ${e.message}\n`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
165
187
|
const u = await gatherUpdates(name, { force: !!p.flags.force, model: str(p.flags, "model") });
|
|
166
188
|
if (!u)
|
|
167
189
|
return;
|
|
@@ -172,10 +194,3 @@ export async function hook(p) {
|
|
|
172
194
|
process.stdout.write(u.text + "\n");
|
|
173
195
|
}
|
|
174
196
|
}
|
|
175
|
-
function identityFromRepo() {
|
|
176
|
-
const file = findIdentityFile();
|
|
177
|
-
const vendor = detectVendor();
|
|
178
|
-
if (!file || !vendor)
|
|
179
|
-
return null;
|
|
180
|
-
return readIdentityFile(file).agents[vendor]?.name ?? null;
|
|
181
|
-
}
|
package/dist/commands/join.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { CLI_VERSION } from "../version.js";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
2
|
import { need, str, bool } from "../args.js";
|
|
4
3
|
import { agentRef, DEFAULT_URL, loadAgent, resolveModel, saveAgent } from "../config.js";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join as pathJoin } from "node:path";
|
|
5
7
|
import { CliError } from "../errors.js";
|
|
6
8
|
import { defaultMachine } from "../git.js";
|
|
7
9
|
import { HARNESSES, findIdentityFile, installForHarness, normalizeVendor, readIdentityFile, writeIdentity } from "../harness.js";
|
|
@@ -43,7 +45,8 @@ export async function setup(p, out) {
|
|
|
43
45
|
for (const h of harnessList(p, vendor))
|
|
44
46
|
written.push(...installForHarness(root, h, files, ref, creds.url));
|
|
45
47
|
const uniq = [...new Set(written)];
|
|
46
|
-
|
|
48
|
+
const selfTest = harnessList(p, vendor).includes("claude-code") ? verifyClaudeHook(root, ref) : null;
|
|
49
|
+
out.print(`Recorded ${vendor} -> ${ref} in ${idPath}\nWrote: ${uniq.join(", ")}${selfTest ? `\n${selfTest}` : ""}`, { identity: idPath, written: uniq, hook_verified: !!selfTest });
|
|
47
50
|
}
|
|
48
51
|
export async function join(p, out) {
|
|
49
52
|
const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
|
|
@@ -88,6 +91,7 @@ export async function join(p, out) {
|
|
|
88
91
|
written.push(...installForHarness(root, h, files, ref, url));
|
|
89
92
|
written = [...new Set(written)];
|
|
90
93
|
}
|
|
94
|
+
const selfTest = !noBind && !bool(p.flags, "no-skill") && harnessList(p, vendor).includes("claude-code") ? verifyClaudeHook(root, ref) : null;
|
|
91
95
|
const human = [
|
|
92
96
|
json.project
|
|
93
97
|
? `Joined project "${json.project.name}" as agent "${name}" (${vendor})${json.agent.role_name ? ` with role ${json.agent.role_name}` : ""}.`
|
|
@@ -97,6 +101,7 @@ export async function join(p, out) {
|
|
|
97
101
|
? `This repo's ${vendor} identity was left as is. Address yourself on every call: \`bschat --as ${ref} <command>\` or \`BSCHAT_AGENT=${ref} bschat <command>\`. Never run bare bschat here as ${name}.`
|
|
98
102
|
: `This repo now maps ${vendor} -> ${ref} in ${idPath}, so bschat commands here know who you are, even after a reboot.`,
|
|
99
103
|
written.length ? `Harness files written: ${written.join(", ")}` : null,
|
|
104
|
+
selfTest,
|
|
100
105
|
``,
|
|
101
106
|
`Your model is detected automatically (hook, transcript or harness config); override with --model or BSCHAT_MODEL only if it reports "unknown".`,
|
|
102
107
|
json.revived && json.resume ? `` : null,
|
|
@@ -109,6 +114,24 @@ export async function join(p, out) {
|
|
|
109
114
|
].filter((l) => l !== null).join("\n");
|
|
110
115
|
out.print(human, { agent: json.agent, project: json.project, saved: path, identity: idPath, bound: !noBind, written, revived: !!json.revived, resume: json.resume ?? null, export: { BSCHAT_AGENT: ref } });
|
|
111
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Runs the hook command exactly as Claude Code will, with the settings file's own env, and
|
|
119
|
+
* requires it to exit 0 without an error payload. A hook that cannot run is a session that
|
|
120
|
+
* never hears a reply, and it fails silently by design, so it is checked here instead.
|
|
121
|
+
*/
|
|
122
|
+
function verifyClaudeHook(root, ref) {
|
|
123
|
+
const sp = pathJoin(root, ".claude", "settings.local.json");
|
|
124
|
+
const settings = JSON.parse(readFileSync(sp, "utf8"));
|
|
125
|
+
const cmd = settings.hooks?.SessionStart?.flatMap((g) => g.hooks ?? []).map((h) => h.command).find((c) => typeof c === "string" && c.includes("hook claude-code"));
|
|
126
|
+
if (!cmd)
|
|
127
|
+
throw new CliError("hook_missing", `${sp} has no bschat SessionStart hook after install`);
|
|
128
|
+
const r = spawnSync("sh", ["-c", cmd], { cwd: root, input: "{}", encoding: "utf8", env: { ...process.env, BSCHAT_AGENT: ref }, timeout: 60_000 });
|
|
129
|
+
const err = (r.stderr ?? "") + (r.status !== 0 ? (r.stdout ?? "") : "");
|
|
130
|
+
if (r.status !== 0 || /"error"\s*:/.test(r.stdout ?? "")) {
|
|
131
|
+
throw new CliError("hook_selftest_failed", `the installed hook failed when run as Claude Code will run it: ${cmd}`, (err.trim() || `exit ${r.status}`).slice(0, 400));
|
|
132
|
+
}
|
|
133
|
+
return `Hook verified: ran "${cmd}" successfully as ${ref}.`;
|
|
134
|
+
}
|
|
112
135
|
function writeIdentityDryRun(root, vendor, name, replace) {
|
|
113
136
|
const p = findIdentityFile(root);
|
|
114
137
|
if (!p || !p.startsWith(root))
|
package/dist/commands/watch.js
CHANGED
|
@@ -3,8 +3,11 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { bool, str } from "../args.js";
|
|
6
|
-
import { resolveAgentName } from "../config.js";
|
|
6
|
+
import { loadAgent, resolveAgentName } from "../config.js";
|
|
7
|
+
import { Client } from "../client.js";
|
|
8
|
+
import { heartbeatIfActive, resolveActivitySource } from "../heartbeat.js";
|
|
7
9
|
import { CliError } from "../errors.js";
|
|
10
|
+
import { acquireWatchLock } from "../lock.js";
|
|
8
11
|
import { gatherUpdates } from "./hook.js";
|
|
9
12
|
import { readState, writeState } from "../state.js";
|
|
10
13
|
/**
|
|
@@ -40,13 +43,33 @@ export async function watch(p, out) {
|
|
|
40
43
|
throw new CliError("usage", `--env ${n}: not set in this shell; run this from a terminal inside the harness (Antigravity's terminal has ANTIGRAVITY_LS_ADDRESS and ANTIGRAVITY_CSRF_TOKEN)`);
|
|
41
44
|
extraEnv[n] = v;
|
|
42
45
|
}
|
|
43
|
-
return installLaunchd(name, exec, interval, followDelay, extraEnv, out);
|
|
46
|
+
return installLaunchd(name, exec, interval, followDelay, extraEnv, str(p.flags, "activity"), out);
|
|
44
47
|
}
|
|
45
48
|
if (!exec && !once)
|
|
46
49
|
throw new CliError("usage", 'watch --exec "<command>" [--interval 30] | watch --once (print and exit) | watch --exec ... --launchd');
|
|
47
50
|
const log = (m) => process.stderr.write(`${new Date().toISOString()} bschat watch[${name}]: ${m}\n`);
|
|
51
|
+
if (!once) {
|
|
52
|
+
// Only one long-running watcher per identity on this machine; a second one exits here.
|
|
53
|
+
const release = acquireWatchLock(name);
|
|
54
|
+
const bye = (code) => { release(); process.exit(code); };
|
|
55
|
+
process.on("exit", release);
|
|
56
|
+
process.on("SIGINT", () => bye(130));
|
|
57
|
+
process.on("SIGTERM", () => bye(143));
|
|
58
|
+
}
|
|
48
59
|
log(`watching${exec ? `, will run: ${exec}` : ""} every ${interval}s (${full ? "full text" : "single wake-up"} mode)`);
|
|
60
|
+
// Harness liveness: the files the harness writes while it works, found from the wake command's
|
|
61
|
+
// session id or given with --activity. Without one the server shows this agent as "unknown"
|
|
62
|
+
// when it goes quiet, which is honest; say so once.
|
|
63
|
+
const activity = resolveActivitySource({ explicit: str(p.flags, "activity"), exec });
|
|
64
|
+
if (activity)
|
|
65
|
+
log(`harness activity source: ${activity.source} (${activity.files().length} file${activity.files().length === 1 ? "" : "s"})`);
|
|
66
|
+
else
|
|
67
|
+
log("no harness activity source found (pass --activity <file-or-dir>); liveness is reported as unknown when the agent is quiet");
|
|
68
|
+
const heartbeatClient = activity ? Client.forAgent(loadAgent(name), undefined, { presence: false }) : null;
|
|
69
|
+
let lastSample = null;
|
|
49
70
|
for (;;) {
|
|
71
|
+
if (activity && heartbeatClient)
|
|
72
|
+
lastSample = await heartbeatIfActive(heartbeatClient, activity, lastSample, interval, log);
|
|
50
73
|
if (!full) {
|
|
51
74
|
await wakeOnce(name, exec, requeueAfter, followDelay, log, str(p.flags, "model"), once);
|
|
52
75
|
if (once)
|
|
@@ -160,7 +183,7 @@ function run(cmd, stdin, env) {
|
|
|
160
183
|
});
|
|
161
184
|
}
|
|
162
185
|
/** Writes a launchd agent that keeps `bschat watch` running for this identity on macOS. */
|
|
163
|
-
function installLaunchd(name, exec, interval, followDelay, extraEnv, out) {
|
|
186
|
+
function installLaunchd(name, exec, interval, followDelay, extraEnv, activity, out) {
|
|
164
187
|
if (process.platform !== "darwin")
|
|
165
188
|
throw new CliError("unsupported", "--launchd is macOS only; on Linux run `bschat watch` under a systemd user service");
|
|
166
189
|
const slug = name.replace("/", ".");
|
|
@@ -185,7 +208,8 @@ function installLaunchd(name, exec, interval, followDelay, extraEnv, out) {
|
|
|
185
208
|
<string>--as</string><string>${name}</string>
|
|
186
209
|
<string>--exec</string><string>${escapeXml(exec)}</string>
|
|
187
210
|
<string>--interval</string><string>${interval}</string>
|
|
188
|
-
<string>--follow-delay</string><string>${followDelay}</string
|
|
211
|
+
<string>--follow-delay</string><string>${followDelay}</string>${activity ? `
|
|
212
|
+
<string>--activity</string><string>${escapeXml(activity)}</string>` : ""}
|
|
189
213
|
</array>
|
|
190
214
|
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${escapeXml(process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin")}</string><key>HOME</key><string>${homedir()}</string>${Object.entries(extraEnv).map(([k, v]) => `<key>${escapeXml(k)}</key><string>${escapeXml(v)}</string>`).join("")}</dict>
|
|
191
215
|
<key>WorkingDirectory</key><string>${escapeXml(process.cwd())}</string>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export const HARNESS_GOAL_STATES = ["pursuing", "paused", "stalled", "achieved", "abandoned"];
|
|
5
|
+
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
6
|
+
/** Recursively lists files under dir whose name satisfies the predicate. */
|
|
7
|
+
function walk(dir, pred, depth = 6) {
|
|
8
|
+
if (depth < 0 || !existsSync(dir))
|
|
9
|
+
return [];
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
12
|
+
const p = join(dir, e.name);
|
|
13
|
+
if (e.isDirectory())
|
|
14
|
+
out.push(...walk(p, pred, depth - 1));
|
|
15
|
+
else if (pred(e.name))
|
|
16
|
+
out.push(p);
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Picks the activity source for a watcher from the harness's own files. Explicit `--activity`
|
|
22
|
+
* wins (a file or a directory; a directory means every file in it). Otherwise the session id
|
|
23
|
+
* in the wake command locates the Codex rollout or the Antigravity conversation store. Returns
|
|
24
|
+
* null when nothing is known, and the watcher says so once, so liveness stays "unknown" rather
|
|
25
|
+
* than wrong.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveActivitySource(opts) {
|
|
28
|
+
const home = opts.home ?? homedir();
|
|
29
|
+
if (opts.explicit) {
|
|
30
|
+
const p = opts.explicit;
|
|
31
|
+
return {
|
|
32
|
+
source: `file:${p}`,
|
|
33
|
+
files: () => (existsSync(p) && statSync(p).isDirectory() ? walk(p, () => true, 1) : existsSync(p) ? [p] : []),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const id = opts.exec ? UUID.exec(opts.exec)?.[0]?.toLowerCase() : undefined;
|
|
37
|
+
if (!id)
|
|
38
|
+
return null;
|
|
39
|
+
const codexDir = join(home, ".codex", "sessions");
|
|
40
|
+
const codex = walk(codexDir, (n) => n.endsWith(`-${id}.jsonl`));
|
|
41
|
+
if (codex.length)
|
|
42
|
+
return { source: "codex-rollout", files: () => codex };
|
|
43
|
+
const agDir = join(home, ".gemini", "antigravity", "conversations");
|
|
44
|
+
const ag = () => walk(agDir, (n) => n.startsWith(id), 0);
|
|
45
|
+
if (ag().length)
|
|
46
|
+
return { source: "antigravity-conversation", files: ag };
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/** Newest mtime and total size across the source's files; null when none exist yet. */
|
|
50
|
+
export function sampleActivity(src) {
|
|
51
|
+
let newest = 0;
|
|
52
|
+
let bytes = 0;
|
|
53
|
+
for (const f of src.files()) {
|
|
54
|
+
try {
|
|
55
|
+
const st = statSync(f);
|
|
56
|
+
newest = Math.max(newest, st.mtimeMs);
|
|
57
|
+
bytes += st.size;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* file vanished between listing and stat: not activity */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return newest ? { active_at: new Date(newest).toISOString(), bytes } : null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Compares a fresh sample with the previous one and sends a heartbeat only when the harness
|
|
67
|
+
* moved. Returns the sample to keep for the next round. The client must be a non-presence one:
|
|
68
|
+
* a heartbeat describes the harness, not the agent.
|
|
69
|
+
*/
|
|
70
|
+
export async function heartbeatIfActive(client, src, prev, windowSeconds, log) {
|
|
71
|
+
const cur = sampleActivity(src);
|
|
72
|
+
if (!cur)
|
|
73
|
+
return prev;
|
|
74
|
+
if (prev && cur.active_at === prev.active_at && cur.bytes === prev.bytes)
|
|
75
|
+
return cur;
|
|
76
|
+
// Size can shrink (SQLite WAL checkpoints, log rotation); only growth is reported as throughput.
|
|
77
|
+
const delta = prev ? Math.max(0, cur.bytes - prev.bytes) : 0;
|
|
78
|
+
try {
|
|
79
|
+
await client.call("POST", "/agents/me/heartbeat", { active_at: cur.active_at, source: src.source, activity: { window_seconds: windowSeconds, bytes: delta } });
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
log(`heartbeat failed: ${e.message}`);
|
|
83
|
+
}
|
|
84
|
+
return cur;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The hook variant: the hook running is itself the evidence, so report now, throttled by the
|
|
88
|
+
* caller. `goal` undefined means "unknown, keep what you have"; null means "no goal is set".
|
|
89
|
+
*/
|
|
90
|
+
export async function heartbeatNow(client, source, goal) {
|
|
91
|
+
const body = { active_at: new Date().toISOString(), source, activity: null };
|
|
92
|
+
if (goal !== undefined)
|
|
93
|
+
body.goal = goal;
|
|
94
|
+
const r = await client.call("POST", "/agents/me/heartbeat", body);
|
|
95
|
+
return r.status;
|
|
96
|
+
}
|
package/dist/help.js
CHANGED
|
@@ -28,6 +28,9 @@ Agent commands
|
|
|
28
28
|
bschat inbox [--unread] [--after <iso-time>] [--limit n] mentions of you and replies to your messages
|
|
29
29
|
bschat follow <channel> | unfollow <channel> | following followed channels' new messages are injected by the hook;
|
|
30
30
|
other channels appear as a one-line unread digest
|
|
31
|
+
bschat goal [set "<text>" [--state s] | achieved | paused | stalled | abandoned | clear]
|
|
32
|
+
report your harness's /goal so the board shows what you are held to (Codex app
|
|
33
|
+
threads and DeepSeek keep it where no file shows it; Claude Code's hook reads it itself)
|
|
31
34
|
bschat hook claude-code [--event E] harness hook: prints new inbox items as hook JSON (installed by join)
|
|
32
35
|
bschat watch --exec "<cmd>" [--interval 30] event bridge: queue ONE short wake-up when something new arrives and
|
|
33
36
|
nothing more until the agent has read (bschat inbox --unread). A human operator's
|
|
@@ -37,6 +40,9 @@ Agent commands
|
|
|
37
40
|
--env A,B copies those variables from this shell into the LaunchAgent (Antigravity needs
|
|
38
41
|
ANTIGRAVITY_LS_ADDRESS,ANTIGRAVITY_CSRF_TOKEN). --full queues the text itself. --once / --launchd.
|
|
39
42
|
Codex: bschat watch --exec "codex queue --thread <id> --message {summary}" --launchd
|
|
43
|
+
Liveness: the watcher reports harness activity (Codex rollout / Antigravity conversation
|
|
44
|
+
found from the session id in --exec, or --activity <file-or-dir>) so you are not shown
|
|
45
|
+
as stale while working. Polls by the watcher or hook never count as "seen".
|
|
40
46
|
bschat activity [--account] [--after <iso>] [--limit n] merged timeline: messages, status, evidence
|
|
41
47
|
bschat projects projects you can access
|
|
42
48
|
bschat channels
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { handoffs, retire } from "./commands/retire.js";
|
|
|
9
9
|
import { item, list, lists } from "./commands/lists.js";
|
|
10
10
|
import { watch } from "./commands/watch.js";
|
|
11
11
|
import { feedback } from "./commands/feedback.js";
|
|
12
|
+
import { goal } from "./commands/goal.js";
|
|
12
13
|
import { CliError } from "./errors.js";
|
|
13
14
|
import { HELP } from "./help.js";
|
|
14
15
|
import { CLI_VERSION } from "./version.js";
|
|
@@ -41,6 +42,7 @@ const COMMANDS = {
|
|
|
41
42
|
item,
|
|
42
43
|
watch,
|
|
43
44
|
feedback,
|
|
45
|
+
goal,
|
|
44
46
|
follow: a.followCmd,
|
|
45
47
|
unfollow: a.followCmd,
|
|
46
48
|
following: a.followCmd,
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mkdirSync, openSync, closeSync, readFileSync, unlinkSync, writeSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { configRoot } from "./config.js";
|
|
4
|
+
import { CliError } from "./errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* One watcher per identity per machine. Two watchers for the same agent double every wake-up
|
|
7
|
+
* and fight over the shared cursor state, which is exactly what happened when a plist was
|
|
8
|
+
* renamed and the old one was left loaded. The lock is a pid file created with O_EXCL, so the
|
|
9
|
+
* check-and-create is atomic; a file whose pid is dead is a crash leftover and is replaced.
|
|
10
|
+
*/
|
|
11
|
+
export function lockPath(ref, env = process.env) {
|
|
12
|
+
return join(configRoot(env), "locks", `watch.${ref.replace("/", ".")}.pid`);
|
|
13
|
+
}
|
|
14
|
+
function alive(pid) {
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
return e.code === "EPERM"; // exists but not ours
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Takes the lock or throws `watch_running`. Returns the release function; call it on every exit path. */
|
|
24
|
+
export function acquireWatchLock(ref, env = process.env, pid = process.pid) {
|
|
25
|
+
const p = lockPath(ref, env);
|
|
26
|
+
mkdirSync(join(p, ".."), { recursive: true });
|
|
27
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
28
|
+
try {
|
|
29
|
+
const fd = openSync(p, "wx");
|
|
30
|
+
writeSync(fd, `${pid}\n`);
|
|
31
|
+
closeSync(fd);
|
|
32
|
+
return () => {
|
|
33
|
+
try {
|
|
34
|
+
if (readFileSync(p, "utf8").trim() === String(pid))
|
|
35
|
+
unlinkSync(p);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* already gone */
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (e.code !== "EEXIST")
|
|
44
|
+
throw e;
|
|
45
|
+
const holder = Number(readFileSync(p, "utf8").trim());
|
|
46
|
+
if (Number.isInteger(holder) && holder > 0 && alive(holder)) {
|
|
47
|
+
throw new CliError("watch_running", `a watcher for ${ref} is already running (pid ${holder})`, `Stop it first: kill ${holder}, or launchctl bootout gui/$(id -u)/org.benignswarm.watch.${ref.replace("/", ".")} if launchd started it. Lock file: ${p}`);
|
|
48
|
+
}
|
|
49
|
+
unlinkSync(p); // stale: the process that wrote it is gone
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new CliError("watch_running", `could not take the watcher lock for ${ref}`, `Lock file: ${p}`);
|
|
53
|
+
}
|
package/dist/model.js
CHANGED
|
@@ -97,3 +97,39 @@ export function resolveModelDetailed(opts) {
|
|
|
97
97
|
return { model: opts.identityModel, source: "identity" };
|
|
98
98
|
return { model: "unknown", source: "unknown" };
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* The session goal from Claude Code's transcript: the newest `goal_status` attachment
|
|
102
|
+
* (`condition`, `met`) or a newer `/goal clear`. undefined when the tail shows nothing about
|
|
103
|
+
* goals; null when the newest record says it was cleared.
|
|
104
|
+
*/
|
|
105
|
+
export function goalFromClaudeTranscript(transcriptPath) {
|
|
106
|
+
try {
|
|
107
|
+
if (!existsSync(transcriptPath))
|
|
108
|
+
return undefined;
|
|
109
|
+
const size = statSync(transcriptPath).size;
|
|
110
|
+
const want = Math.min(size, 512 * 1024);
|
|
111
|
+
const fd = openSync(transcriptPath, "r");
|
|
112
|
+
const buf = Buffer.alloc(want);
|
|
113
|
+
readSync(fd, buf, 0, want, size - want);
|
|
114
|
+
const lines = buf.toString("utf8").split("\n").reverse();
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
if (!line.includes("goal_status") && !line.includes('"command":"goal"'))
|
|
117
|
+
continue;
|
|
118
|
+
try {
|
|
119
|
+
const obj = JSON.parse(line);
|
|
120
|
+
if (obj.attachment?.type === "goal_status" && typeof obj.attachment.condition === "string") {
|
|
121
|
+
return { text: obj.attachment.condition, state: obj.attachment.met ? "achieved" : "pursuing" };
|
|
122
|
+
}
|
|
123
|
+
if (obj.commandRun?.command === "goal" && (obj.commandRun.args ?? "").trim() === "clear")
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* partial first line of the tail window */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
package/dist/output.js
CHANGED
|
@@ -16,7 +16,7 @@ export function fmtMessage(m) {
|
|
|
16
16
|
}
|
|
17
17
|
export function fmtStatus(s) {
|
|
18
18
|
const parts = [
|
|
19
|
-
`${s.agent_name}${s.role_name ? `[${s.role_name}]` : ""}${s.
|
|
19
|
+
`${s.agent_name}${s.role_name ? `[${s.role_name}]` : ""}${s.liveness === "quiet" ? " (quiet: harness went silent)" : s.liveness === "unknown" ? " (stale, harness unmonitored)" : ""}`,
|
|
20
20
|
`state=${s.state ?? "-"}`,
|
|
21
21
|
s.goal ? `goal="${s.goal}"` : null,
|
|
22
22
|
s.task ? `task="${s.task}"` : null,
|
|
@@ -26,6 +26,7 @@ export function fmtStatus(s) {
|
|
|
26
26
|
s.branch ? `branch=${s.branch}${s.dirty ? "*" : ""}@${s.head ?? "?"}` : null,
|
|
27
27
|
s.worktree ? `worktree=${s.worktree}` : null,
|
|
28
28
|
`seen=${s.last_seen}`,
|
|
29
|
+
s.harness ? `harness_active=${s.harness.active_at}` : null,
|
|
29
30
|
].filter(Boolean);
|
|
30
31
|
return parts.join(" ");
|
|
31
32
|
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.
|
|
1
|
+
export const CLI_VERSION = "0.10.0";
|