benignswarm 0.8.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/args.js +1 -1
- package/dist/client.js +50 -23
- package/dist/commands/agent.js +24 -18
- package/dist/commands/evidence.js +1 -1
- package/dist/commands/goal.js +55 -0
- package/dist/commands/hook.js +31 -16
- package/dist/commands/join.js +41 -12
- package/dist/commands/watch.js +47 -11
- package/dist/config.js +113 -18
- package/dist/harness.js +13 -6
- package/dist/heartbeat.js +96 -0
- package/dist/help.js +10 -2
- 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/state.js +21 -6
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/args.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
2
|
import { CliError } from "./errors.js";
|
|
3
|
-
const STRING_FLAGS = ["as", "admin", "key", "url", "label", "model", "since", "limit", "after", "topic", "goal", "task", "state", "blocked-on", "tags", "custom", "vendor", "role", "description", "permissions", "cardinality", "machine", "dir", "confirm", "type", "name", "out", "token", "prefix", "uses", "expires-hours", "skill-dir", "harness", "reply-to", "event", "project", "projects", "post", "title", "body", "body-file", "handoff-to", "evidence", "agent", "parent", "parent-item", "channel", "statuses", "assign", "if-status", "status", "exec", "interval", "requeue-after", "category", "command", "follow-delay"];
|
|
3
|
+
const STRING_FLAGS = ["as", "admin", "key", "url", "label", "model", "since", "limit", "after", "topic", "goal", "task", "state", "blocked-on", "tags", "custom", "vendor", "role", "description", "permissions", "cardinality", "machine", "dir", "confirm", "type", "name", "out", "token", "prefix", "uses", "expires-hours", "skill-dir", "harness", "reply-to", "event", "project", "projects", "post", "title", "body", "body-file", "handoff-to", "evidence", "agent", "parent", "parent-item", "channel", "statuses", "assign", "if-status", "status", "exec", "interval", "requeue-after", "category", "command", "follow-delay", "env"];
|
|
4
4
|
const BOOL_FLAGS = ["json", "follow", "account", "help", "version", "broadcast", "create-channels", "read-account-status", "replace-key", "mentions-me", "no-skill", "replace", "unread", "force", "no-hooks", "yes", "open", "bind-here", "peek", "once", "launchd", "full", "no-bind"];
|
|
5
5
|
export function parse(argv) {
|
|
6
6
|
const options = {};
|
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,8 +1,9 @@
|
|
|
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";
|
|
5
|
-
import { DEFAULT_URL, listSaved, loadAgent, removeAgent, resolveAgentName, resolveModel, saveAgent } from "../config.js";
|
|
6
|
+
import { agentRef, DEFAULT_URL, listSaved, loadAgent, removeAgent, resolveAgentName, resolveModel, saveAgent } from "../config.js";
|
|
6
7
|
import { CliError } from "../errors.js";
|
|
7
8
|
import { defaultMachine } from "../git.js";
|
|
8
9
|
import { fmtMessage, fmtStatus, table } from "../output.js";
|
|
@@ -10,7 +11,7 @@ import { readState, writeState } from "../state.js";
|
|
|
10
11
|
function agentClient(p) {
|
|
11
12
|
const name = resolveAgentName(str(p.flags, "as"));
|
|
12
13
|
const creds = loadAgent(name);
|
|
13
|
-
const model = resolveModel(str(p.flags, "model"));
|
|
14
|
+
const model = resolveModel(str(p.flags, "model"), process.env, process.cwd(), name);
|
|
14
15
|
return { client: Client.forAgent(creds, model), creds };
|
|
15
16
|
}
|
|
16
17
|
/**
|
|
@@ -26,9 +27,10 @@ export function projectOf(p, creds) {
|
|
|
26
27
|
return v;
|
|
27
28
|
}
|
|
28
29
|
export async function login(p, out) {
|
|
29
|
-
const
|
|
30
|
-
if (!
|
|
30
|
+
const asFlag = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
|
|
31
|
+
if (!asFlag)
|
|
31
32
|
throw new CliError("usage", "login needs --as <agent-name>");
|
|
33
|
+
const name = asFlag.includes("/") ? asFlag.slice(asFlag.indexOf("/") + 1) : asFlag;
|
|
32
34
|
const key = need(p.flags, "key");
|
|
33
35
|
if (!key.startsWith("bs_agent_"))
|
|
34
36
|
throw new CliError("usage", "login expects an agent key (bs_agent_...). For admin keys use: bschat admin login");
|
|
@@ -41,23 +43,24 @@ export async function login(p, out) {
|
|
|
41
43
|
}
|
|
42
44
|
const project = me.project ? me.project.name : null;
|
|
43
45
|
const path = saveAgent({ url, key, account: me.project ? me.project.id : "", project, agent: name, vendor: me.agent.vendor, machine: defaultMachine(str(p.flags, "label")) });
|
|
44
|
-
|
|
46
|
+
const ref = agentRef({ project, agent: name });
|
|
47
|
+
out.print(`Saved identity ${ref} (${project ? `project ${project}` : "account-scoped"}) to ${path}\nNext: export BSCHAT_AGENT=${ref}`, { saved: path, agent: name, project, ref });
|
|
45
48
|
}
|
|
46
49
|
export async function logout(p, out) {
|
|
47
50
|
const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
|
|
48
51
|
if (!name)
|
|
49
52
|
throw new CliError("usage", "logout needs --as <agent-name>");
|
|
50
|
-
removeAgent(name);
|
|
51
|
-
out.print(`Removed saved identity ${
|
|
53
|
+
const ref = removeAgent(name);
|
|
54
|
+
out.print(`Removed saved identity ${ref}`, { removed: ref });
|
|
52
55
|
}
|
|
53
56
|
export async function identities(_p, out) {
|
|
54
57
|
const names = listSaved("agents");
|
|
55
58
|
const rows = names.map((n) => {
|
|
56
59
|
const c = loadAgent(n);
|
|
57
|
-
return {
|
|
60
|
+
return { ref: n, agent: c.agent, project: c.project ?? "(account)", url: c.url, machine: c.machine };
|
|
58
61
|
});
|
|
59
62
|
const current = process.env.BSCHAT_AGENT ?? "(BSCHAT_AGENT not set)";
|
|
60
|
-
out.print(`${table(rows, ["agent", "project", "url", "machine"])}\n\ncurrent: ${current}`, { identities: rows, current: process.env.BSCHAT_AGENT ?? null });
|
|
63
|
+
out.print(`${table(rows, ["ref", "agent", "project", "url", "machine"])}\n\ncurrent: ${current}`, { identities: rows, current: process.env.BSCHAT_AGENT ?? null });
|
|
61
64
|
}
|
|
62
65
|
export async function me(p, out) {
|
|
63
66
|
const { client } = agentClient(p);
|
|
@@ -140,14 +143,17 @@ export async function send(p, out) {
|
|
|
140
143
|
if (replyTo !== undefined && !/^\d+$/.test(replyTo))
|
|
141
144
|
throw new CliError("usage", "--reply-to takes the seq number of the message you are answering");
|
|
142
145
|
const project = projectOf(p, creds);
|
|
143
|
-
|
|
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 });
|
|
144
150
|
{
|
|
145
151
|
// Our own post must not count as unread for us; advance the cursor if we were caught up.
|
|
146
|
-
const st = readState(creds
|
|
152
|
+
const st = readState(agentRef(creds));
|
|
147
153
|
const key = `${project}/${channel}`;
|
|
148
154
|
const last = st.channel_read?.[key];
|
|
149
155
|
if (last !== undefined && last >= r.message.seq - 1)
|
|
150
|
-
writeState(creds
|
|
156
|
+
writeState(agentRef(creds), { ...st, channel_read: { ...(st.channel_read ?? {}), [key]: r.message.seq } });
|
|
151
157
|
}
|
|
152
158
|
out.print(`sent #${channel} seq ${r.message.seq}${r.message.reply_to_seq ? ` (reply to #${r.message.reply_to_seq})` : ""}${r.message.mentions.length ? ` (mentions ${r.message.mentions.length})` : ""}`, r);
|
|
153
159
|
}
|
|
@@ -168,8 +174,8 @@ export async function read(p, out) {
|
|
|
168
174
|
qs.set("limit", str(p.flags, "limit") ?? "100");
|
|
169
175
|
const r = await client.call("GET", `/projects/${project}/channels/${channel}/messages?${qs}`);
|
|
170
176
|
const text = r.messages.length ? r.messages.map(fmtMessage).join("\n") : "(no messages)";
|
|
171
|
-
const st = readState(creds
|
|
172
|
-
writeState(creds
|
|
177
|
+
const st = readState(agentRef(creds));
|
|
178
|
+
writeState(agentRef(creds), { ...st, channel_read: { ...(st.channel_read ?? {}), [`${project}/${channel}`]: r.cursor } });
|
|
173
179
|
out.print(`${text}\ncursor: ${r.cursor}`, r);
|
|
174
180
|
}
|
|
175
181
|
function follow(client, path, out) {
|
|
@@ -203,14 +209,14 @@ export async function inbox(p, out) {
|
|
|
203
209
|
const { client, creds } = agentClient(p);
|
|
204
210
|
const qs = new URLSearchParams();
|
|
205
211
|
const unread = bool(p.flags, "unread");
|
|
206
|
-
const state = readState(creds
|
|
212
|
+
const state = readState(agentRef(creds));
|
|
207
213
|
const after = str(p.flags, "after") ?? (unread ? state.inbox_after : undefined);
|
|
208
214
|
if (after)
|
|
209
215
|
qs.set("after", after);
|
|
210
216
|
qs.set("limit", str(p.flags, "limit") ?? "50");
|
|
211
217
|
const r = await client.call("GET", `/inbox?${qs}`);
|
|
212
218
|
if (unread && r.messages.length)
|
|
213
|
-
writeState(creds
|
|
219
|
+
writeState(agentRef(creds), { ...state, inbox_after: r.messages[r.messages.length - 1].created_at, last_check: Date.now() });
|
|
214
220
|
const fmt = (m) => `${creds.project ? "" : `[${m.project}] `}${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`;
|
|
215
221
|
const items = (r.items ?? []).map((i) => `list "${i.list}": [${i.status}] ${i.title} (item ${i.id})`);
|
|
216
222
|
const text = [...r.messages.map(fmt), ...(items.length ? ["assigned to you:", ...items] : [])].join("\n");
|
|
@@ -294,7 +300,7 @@ export async function followCmd(p, out) {
|
|
|
294
300
|
const channel = p.positionals[1];
|
|
295
301
|
const { creds, client } = agentClient(p);
|
|
296
302
|
const unfollow = p.positionals[0] === "unfollow";
|
|
297
|
-
const st = readState(creds
|
|
303
|
+
const st = readState(agentRef(creds));
|
|
298
304
|
const follows = new Set(st.follows ?? []);
|
|
299
305
|
if (p.positionals[0] === "following" || !channel) {
|
|
300
306
|
out.print(follows.size ? [...follows].join("\n") : "(following no channels; the hook shows a digest line only)", { follows: [...follows] });
|
|
@@ -309,6 +315,6 @@ export async function followCmd(p, out) {
|
|
|
309
315
|
await client.call("GET", `/projects/${project}/channels/${channel}/messages?limit=1`); // validates access
|
|
310
316
|
follows.add(key);
|
|
311
317
|
}
|
|
312
|
-
writeState(creds
|
|
318
|
+
writeState(agentRef(creds), { ...st, follows: [...follows] });
|
|
313
319
|
out.print(`${unfollow ? "unfollowed" : "following"} #${channel} in ${project}`, { follows: [...follows] });
|
|
314
320
|
}
|
|
@@ -21,7 +21,7 @@ export async function evidence(p, out) {
|
|
|
21
21
|
const sub = p.positionals[1];
|
|
22
22
|
const name = resolveAgentName(str(p.flags, "as"));
|
|
23
23
|
const creds = loadAgent(name);
|
|
24
|
-
const client = Client.forAgent(creds, resolveModel(str(p.flags, "model")));
|
|
24
|
+
const client = Client.forAgent(creds, resolveModel(str(p.flags, "model"), process.env, process.cwd(), name));
|
|
25
25
|
const project = projectOf(p, creds);
|
|
26
26
|
if (sub === "post") {
|
|
27
27
|
const file = p.positionals[2];
|
|
@@ -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)
|
|
@@ -78,7 +79,7 @@ export async function gatherUpdates(name, opts) {
|
|
|
78
79
|
if (follows.has(key)) {
|
|
79
80
|
const r = await client.call("GET", `/projects/${project}/channels/${ch.name}/messages?since=${last}&limit=5`);
|
|
80
81
|
const seen = new Set(messages.map((m) => m.id));
|
|
81
|
-
const others = r.messages.filter((m) => m.agent_name !==
|
|
82
|
+
const others = r.messages.filter((m) => m.agent_name !== creds.agent && !seen.has(m.id));
|
|
82
83
|
for (const m of others)
|
|
83
84
|
followed.push(`${creds.project ? "" : `[${project}] `}${fmtMessage(m)}`);
|
|
84
85
|
const shown = r.messages.length ? r.messages[r.messages.length - 1].seq : last;
|
|
@@ -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
|
-
import { DEFAULT_URL, loadAgent, resolveModel, saveAgent } from "../config.js";
|
|
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";
|
|
@@ -33,21 +35,25 @@ export async function setup(p, out) {
|
|
|
33
35
|
if (!name)
|
|
34
36
|
throw new CliError("usage", "setup needs --as <agent-name> (an identity already saved with join or login)");
|
|
35
37
|
const creds = loadAgent(name);
|
|
38
|
+
const ref = agentRef(creds);
|
|
36
39
|
const vendor = normalizeVendor(str(p.flags, "vendor") ?? creds.vendor);
|
|
37
40
|
const root = str(p.flags, "dir") ?? repoRoot();
|
|
38
|
-
const model = resolveModel(str(p.flags, "model"));
|
|
39
|
-
const idPath = writeIdentity(root, vendor,
|
|
41
|
+
const model = resolveModel(str(p.flags, "model"), process.env, process.cwd(), ref);
|
|
42
|
+
const idPath = writeIdentity(root, vendor, creds.agent, model, bool(p.flags, "replace"), creds.project);
|
|
40
43
|
const files = await fetchSkill(creds.url);
|
|
41
44
|
const written = [];
|
|
42
45
|
for (const h of harnessList(p, vendor))
|
|
43
|
-
written.push(...installForHarness(root, h, files,
|
|
46
|
+
written.push(...installForHarness(root, h, files, ref, creds.url));
|
|
44
47
|
const uniq = [...new Set(written)];
|
|
45
|
-
|
|
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 });
|
|
46
50
|
}
|
|
47
51
|
export async function join(p, out) {
|
|
48
52
|
const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
|
|
49
53
|
if (!name)
|
|
50
54
|
throw new CliError("usage", "join needs --as <agent-name> (the name you were given)");
|
|
55
|
+
if (name.includes("/"))
|
|
56
|
+
throw new CliError("usage", "join takes the bare agent name; the project comes from the token");
|
|
51
57
|
const token = need(p.flags, "token");
|
|
52
58
|
if (!token.startsWith("bs_enroll_"))
|
|
53
59
|
throw new CliError("usage", "--token must be an enrollment token (bs_enroll_...)");
|
|
@@ -74,24 +80,28 @@ export async function join(p, out) {
|
|
|
74
80
|
const json = await res.json();
|
|
75
81
|
if (!res.ok)
|
|
76
82
|
throw new CliError(json.error ?? `http_${res.status}`, json.message ?? "join failed");
|
|
77
|
-
const
|
|
78
|
-
const
|
|
83
|
+
const project = json.project ? json.project.name : null;
|
|
84
|
+
const ref = agentRef({ project, agent: name });
|
|
85
|
+
const path = saveAgent({ url, key: json.key, account: json.project ? json.project.id : "", project, agent: name, vendor, machine });
|
|
86
|
+
const idPath = noBind ? null : writeIdentity(root, vendor, name, model, bool(p.flags, "replace"), project);
|
|
79
87
|
let written = [];
|
|
80
88
|
if (!bool(p.flags, "no-skill") && !noBind) {
|
|
81
89
|
const files = await fetchSkill(url);
|
|
82
90
|
for (const h of harnessList(p, vendor))
|
|
83
|
-
written.push(...installForHarness(root, h, files,
|
|
91
|
+
written.push(...installForHarness(root, h, files, ref, url));
|
|
84
92
|
written = [...new Set(written)];
|
|
85
93
|
}
|
|
94
|
+
const selfTest = !noBind && !bool(p.flags, "no-skill") && harnessList(p, vendor).includes("claude-code") ? verifyClaudeHook(root, ref) : null;
|
|
86
95
|
const human = [
|
|
87
96
|
json.project
|
|
88
97
|
? `Joined project "${json.project.name}" as agent "${name}" (${vendor})${json.agent.role_name ? ` with role ${json.agent.role_name}` : ""}.`
|
|
89
98
|
: `Joined as account-wide agent "${name}" (${vendor}) with role ${json.agent.role_name}. Project commands take --project <name>; see them with: bschat me`,
|
|
90
99
|
`Credentials saved to ${path} (private to this machine).`,
|
|
91
100
|
noBind
|
|
92
|
-
? `This repo's ${vendor} identity was left as is. Address yourself on every call: \`bschat --as ${
|
|
93
|
-
: `This repo now maps ${vendor} -> ${
|
|
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}.`
|
|
102
|
+
: `This repo now maps ${vendor} -> ${ref} in ${idPath}, so bschat commands here know who you are, even after a reboot.`,
|
|
94
103
|
written.length ? `Harness files written: ${written.join(", ")}` : null,
|
|
104
|
+
selfTest,
|
|
95
105
|
``,
|
|
96
106
|
`Your model is detected automatically (hook, transcript or harness config); override with --model or BSCHAT_MODEL only if it reports "unknown".`,
|
|
97
107
|
json.revived && json.resume ? `` : null,
|
|
@@ -99,9 +109,28 @@ export async function join(p, out) {
|
|
|
99
109
|
json.revived && json.resume ? `--- ${json.resume.title} ---\n${json.resume.body}\n---${json.resume.evidence.length ? `\nevidence: ${json.resume.evidence.map((e) => `evidence:${e}`).join(" ")}` : ""}` : null,
|
|
100
110
|
json.revived && json.resume ? `Read it before doing anything else.` : null,
|
|
101
111
|
``,
|
|
112
|
+
vendor !== "claude-code" ? `No hook exists for ${vendor}: you will not be told about replies unless you run a watcher. See the skill's "Knowing when someone talks to you" section (bschat watch --launchd).` : null,
|
|
102
113
|
`Next: bschat me && bschat who && bschat inbox && bschat handoffs --open`,
|
|
103
114
|
].filter((l) => l !== null).join("\n");
|
|
104
|
-
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:
|
|
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 } });
|
|
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}.`;
|
|
105
134
|
}
|
|
106
135
|
function writeIdentityDryRun(root, vendor, name, replace) {
|
|
107
136
|
const p = findIdentityFile(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
|
/**
|
|
@@ -32,13 +35,41 @@ export async function watch(p, out) {
|
|
|
32
35
|
if (bool(p.flags, "launchd")) {
|
|
33
36
|
if (!exec)
|
|
34
37
|
throw new CliError("usage", "--launchd needs --exec so the agent knows what to run");
|
|
35
|
-
|
|
38
|
+
const envNames = (str(p.flags, "env") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
39
|
+
const extraEnv = {};
|
|
40
|
+
for (const n of envNames) {
|
|
41
|
+
const v = process.env[n];
|
|
42
|
+
if (v === undefined)
|
|
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)`);
|
|
44
|
+
extraEnv[n] = v;
|
|
45
|
+
}
|
|
46
|
+
return installLaunchd(name, exec, interval, followDelay, extraEnv, str(p.flags, "activity"), out);
|
|
36
47
|
}
|
|
37
48
|
if (!exec && !once)
|
|
38
49
|
throw new CliError("usage", 'watch --exec "<command>" [--interval 30] | watch --once (print and exit) | watch --exec ... --launchd');
|
|
39
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
|
+
}
|
|
40
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;
|
|
41
70
|
for (;;) {
|
|
71
|
+
if (activity && heartbeatClient)
|
|
72
|
+
lastSample = await heartbeatIfActive(heartbeatClient, activity, lastSample, interval, log);
|
|
42
73
|
if (!full) {
|
|
43
74
|
await wakeOnce(name, exec, requeueAfter, followDelay, log, str(p.flags, "model"), once);
|
|
44
75
|
if (once)
|
|
@@ -129,8 +160,11 @@ async function wakeOnce(name, exec, requeueAfter, followDelay, log, model, once)
|
|
|
129
160
|
const cmd = exec.replaceAll("{count}", String(total)).replaceAll("{summary}", shellQuote(summary)).replaceAll("{text}", shellQuote(wake));
|
|
130
161
|
log(`${operatorNew ? "operator message; " : stale ? "re-waking after timeout; " : ""}[${u.tier}] queueing wake-up: ${summary}`);
|
|
131
162
|
const code = await run(cmd, wake, { BSCHAT_TEXT: wake, BSCHAT_SUMMARY: summary, BSCHAT_COUNT: String(total) });
|
|
132
|
-
if (code !== 0)
|
|
133
|
-
|
|
163
|
+
if (code !== 0) {
|
|
164
|
+
// The agent was NOT woken. Leave nothing pending so the next poll tries again.
|
|
165
|
+
log(`wake command exited ${code}; will retry next poll. If this repeats, the harness needs environment the LaunchAgent lacks (see --env).`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
134
168
|
}
|
|
135
169
|
else {
|
|
136
170
|
process.stdout.write(wake + "\n");
|
|
@@ -149,10 +183,11 @@ function run(cmd, stdin, env) {
|
|
|
149
183
|
});
|
|
150
184
|
}
|
|
151
185
|
/** Writes a launchd agent that keeps `bschat watch` running for this identity on macOS. */
|
|
152
|
-
function installLaunchd(name, exec, interval, followDelay, out) {
|
|
186
|
+
function installLaunchd(name, exec, interval, followDelay, extraEnv, activity, out) {
|
|
153
187
|
if (process.platform !== "darwin")
|
|
154
188
|
throw new CliError("unsupported", "--launchd is macOS only; on Linux run `bschat watch` under a systemd user service");
|
|
155
|
-
const
|
|
189
|
+
const slug = name.replace("/", ".");
|
|
190
|
+
const label = `org.benignswarm.watch.${slug}`;
|
|
156
191
|
const dir = join(homedir(), "Library", "LaunchAgents");
|
|
157
192
|
const logDir = join(homedir(), ".bschat", "logs");
|
|
158
193
|
mkdirSync(dir, { recursive: true });
|
|
@@ -173,19 +208,20 @@ function installLaunchd(name, exec, interval, followDelay, out) {
|
|
|
173
208
|
<string>--as</string><string>${name}</string>
|
|
174
209
|
<string>--exec</string><string>${escapeXml(exec)}</string>
|
|
175
210
|
<string>--interval</string><string>${interval}</string>
|
|
176
|
-
<string>--follow-delay</string><string>${followDelay}</string
|
|
211
|
+
<string>--follow-delay</string><string>${followDelay}</string>${activity ? `
|
|
212
|
+
<string>--activity</string><string>${escapeXml(activity)}</string>` : ""}
|
|
177
213
|
</array>
|
|
178
|
-
<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
|
|
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>
|
|
179
215
|
<key>WorkingDirectory</key><string>${escapeXml(process.cwd())}</string>
|
|
180
216
|
<key>RunAtLoad</key><true/>
|
|
181
217
|
<key>KeepAlive</key><true/>
|
|
182
|
-
<key>StandardOutPath</key><string>${join(logDir, `watch.${
|
|
183
|
-
<key>StandardErrorPath</key><string>${join(logDir, `watch.${
|
|
218
|
+
<key>StandardOutPath</key><string>${join(logDir, `watch.${slug}.out.log`)}</string>
|
|
219
|
+
<key>StandardErrorPath</key><string>${join(logDir, `watch.${slug}.err.log`)}</string>
|
|
184
220
|
</dict></plist>
|
|
185
221
|
`;
|
|
186
222
|
const path = join(dir, `${label}.plist`);
|
|
187
223
|
writeFileSync(path, plist);
|
|
188
|
-
out.print(`Wrote ${path}\nStart it: launchctl bootstrap gui/$(id -u) ${path}\nStop it: launchctl bootout gui/$(id -u)/${label}\nLogs: ${logDir}/watch.${
|
|
224
|
+
out.print(`Wrote ${path}${Object.keys(extraEnv).length ? `\nCaptured env: ${Object.keys(extraEnv).join(", ")} (re-run this command if the harness restarts and these change)` : ""}\nStart it: launchctl bootstrap gui/$(id -u) ${path}\nStop it: launchctl bootout gui/$(id -u)/${label}\nLogs: ${logDir}/watch.${slug}.*.log\nThe watcher runs from ${process.cwd()} so the repo identity file applies.`, { plist: path, label });
|
|
189
225
|
}
|
|
190
226
|
function escapeXml(s) {
|
|
191
227
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
package/dist/config.js
CHANGED
|
@@ -11,10 +11,58 @@ export function configRoot(env = process.env) {
|
|
|
11
11
|
return env.BSCHAT_HOME ?? join(homedir(), ".bschat");
|
|
12
12
|
}
|
|
13
13
|
const NAME_RE = /^[a-z0-9][a-z0-9-_]{0,62}$/;
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Saved agent identities are addressed by a ref `<scope>/<name>` where scope is the project
|
|
16
|
+
* name, or `account` for an account-scoped agent. Agent names are only unique per project on
|
|
17
|
+
* the server, so a bare name is ambiguous as soon as two projects on this machine use it; the
|
|
18
|
+
* ref is what keeps their credentials, read cursors and watchers apart.
|
|
19
|
+
*/
|
|
20
|
+
export const ACCOUNT_SCOPE = "account";
|
|
21
|
+
export function agentRef(creds) {
|
|
22
|
+
return `${creds.project ?? ACCOUNT_SCOPE}/${creds.agent}`;
|
|
23
|
+
}
|
|
24
|
+
export function splitRef(ref) {
|
|
25
|
+
const i = ref.indexOf("/");
|
|
26
|
+
if (i < 0)
|
|
27
|
+
throw new CliError("bad_name", `${JSON.stringify(ref)} is not a qualified agent ref (<project>/<name> or ${ACCOUNT_SCOPE}/<name>)`);
|
|
28
|
+
const scope = ref.slice(0, i);
|
|
29
|
+
const name = ref.slice(i + 1);
|
|
30
|
+
if (!NAME_RE.test(scope) || !NAME_RE.test(name))
|
|
31
|
+
throw new CliError("bad_name", `invalid agent ref ${JSON.stringify(ref)}`);
|
|
32
|
+
return { scope, name };
|
|
33
|
+
}
|
|
34
|
+
export function isRef(v) {
|
|
35
|
+
return v.includes("/");
|
|
36
|
+
}
|
|
37
|
+
function adminPath(name, env) {
|
|
15
38
|
if (!NAME_RE.test(name))
|
|
16
|
-
throw new CliError("bad_name", `invalid
|
|
17
|
-
return join(configRoot(env),
|
|
39
|
+
throw new CliError("bad_name", `invalid admin name ${JSON.stringify(name)}`);
|
|
40
|
+
return join(configRoot(env), "admins", `${name}.json`);
|
|
41
|
+
}
|
|
42
|
+
function agentPath(ref, env) {
|
|
43
|
+
const { scope, name } = splitRef(ref);
|
|
44
|
+
return join(configRoot(env), "agents", scope, `${name}.json`);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Versions before 0.9 stored agents as `agents/<name>.json`, keyed by name alone, which let a
|
|
48
|
+
* join in one project silently overwrite the credentials of a same-named agent in another.
|
|
49
|
+
* Each such file records its project, so it is moved into its scope directory on first use.
|
|
50
|
+
*/
|
|
51
|
+
function migrateLegacyAgents(env) {
|
|
52
|
+
const dir = join(configRoot(env), "agents");
|
|
53
|
+
if (!existsSync(dir))
|
|
54
|
+
return;
|
|
55
|
+
for (const f of readdirSync(dir)) {
|
|
56
|
+
if (!f.endsWith(".json"))
|
|
57
|
+
continue;
|
|
58
|
+
const legacy = join(dir, f);
|
|
59
|
+
const creds = JSON.parse(readFileSync(legacy, "utf8"));
|
|
60
|
+
const target = agentPath(agentRef(creds), env);
|
|
61
|
+
if (existsSync(target))
|
|
62
|
+
throw new CliError("legacy_conflict", `${legacy} and ${target} both exist; delete the one that is wrong`);
|
|
63
|
+
save(target, creds);
|
|
64
|
+
unlinkSync(legacy);
|
|
65
|
+
}
|
|
18
66
|
}
|
|
19
67
|
function save(path, data) {
|
|
20
68
|
mkdirSync(join(path, ".."), { recursive: true });
|
|
@@ -22,26 +70,59 @@ function save(path, data) {
|
|
|
22
70
|
chmodSync(path, 0o600);
|
|
23
71
|
}
|
|
24
72
|
export function saveAgent(creds, env = process.env) {
|
|
25
|
-
|
|
73
|
+
migrateLegacyAgents(env);
|
|
74
|
+
const p = agentPath(agentRef(creds), env);
|
|
26
75
|
save(p, creds);
|
|
27
76
|
return p;
|
|
28
77
|
}
|
|
29
78
|
export function saveAdmin(name, creds, env = process.env) {
|
|
30
|
-
const p =
|
|
79
|
+
const p = adminPath(name, env);
|
|
31
80
|
save(p, creds);
|
|
32
81
|
return p;
|
|
33
82
|
}
|
|
34
|
-
export function removeAgent(
|
|
35
|
-
const
|
|
83
|
+
export function removeAgent(nameOrRef, env = process.env) {
|
|
84
|
+
const ref = resolveSavedRef(nameOrRef, env);
|
|
85
|
+
const p = agentPath(ref, env);
|
|
36
86
|
if (!existsSync(p))
|
|
37
|
-
throw new CliError("not_found", `no saved identity ${
|
|
87
|
+
throw new CliError("not_found", `no saved identity ${ref}`);
|
|
38
88
|
unlinkSync(p);
|
|
89
|
+
return ref;
|
|
39
90
|
}
|
|
91
|
+
/** Saved admin nicknames, or saved agent refs (`<scope>/<name>`), sorted. */
|
|
40
92
|
export function listSaved(kind, env = process.env) {
|
|
41
93
|
const dir = join(configRoot(env), kind);
|
|
42
94
|
if (!existsSync(dir))
|
|
43
95
|
return [];
|
|
44
|
-
|
|
96
|
+
if (kind === "admins")
|
|
97
|
+
return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).sort();
|
|
98
|
+
migrateLegacyAgents(env);
|
|
99
|
+
const refs = [];
|
|
100
|
+
for (const scope of readdirSync(dir, { withFileTypes: true })) {
|
|
101
|
+
if (!scope.isDirectory())
|
|
102
|
+
continue;
|
|
103
|
+
for (const f of readdirSync(join(dir, scope.name)))
|
|
104
|
+
if (f.endsWith(".json"))
|
|
105
|
+
refs.push(`${scope.name}/${f.slice(0, -5)}`);
|
|
106
|
+
}
|
|
107
|
+
return refs.sort();
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Turns a bare agent name into its saved ref. A bare name is accepted only while exactly one
|
|
111
|
+
* saved identity carries it; with two (one per project) the caller must say which.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveSavedRef(nameOrRef, env = process.env) {
|
|
114
|
+
if (isRef(nameOrRef)) {
|
|
115
|
+
splitRef(nameOrRef);
|
|
116
|
+
return nameOrRef;
|
|
117
|
+
}
|
|
118
|
+
if (!NAME_RE.test(nameOrRef))
|
|
119
|
+
throw new CliError("bad_name", `invalid agent name ${JSON.stringify(nameOrRef)}`);
|
|
120
|
+
const matches = listSaved("agents", env).filter((r) => splitRef(r).name === nameOrRef);
|
|
121
|
+
if (matches.length === 1)
|
|
122
|
+
return matches[0];
|
|
123
|
+
if (matches.length === 0)
|
|
124
|
+
throw new CliError("not_logged_in", `no saved credentials for agent ${nameOrRef}`, `Run: bschat login --as ${nameOrRef} --key <bs_agent_...>`);
|
|
125
|
+
throw new CliError("ambiguous_identity", `"${nameOrRef}" is saved for more than one project on this machine: ${matches.join(", ")}`, `Say which one you are: --as ${matches[0]} (or BSCHAT_AGENT=${matches[0]}), or run from a checkout whose .bschat/identity.json names it.`);
|
|
45
126
|
}
|
|
46
127
|
/**
|
|
47
128
|
* Resolves which agent identity to use, in order: --as, BSCHAT_AGENT, then the repo-local
|
|
@@ -51,27 +132,35 @@ export function listSaved(kind, env = process.env) {
|
|
|
51
132
|
*/
|
|
52
133
|
export function resolveAgentName(asFlag, env = process.env, cwd = process.cwd()) {
|
|
53
134
|
let name = asFlag ?? env.BSCHAT_AGENT;
|
|
135
|
+
const file = findIdentityFile(cwd);
|
|
136
|
+
if (name && !isRef(name) && file) {
|
|
137
|
+
// A bare name given explicitly: this checkout's identity file may say which project it
|
|
138
|
+
// belongs to (any vendor's entry; the caller may be a second agent of the same vendor).
|
|
139
|
+
const entry = Object.values(readIdentityFile(file).agents).find((e) => e.name === name && e.project !== undefined);
|
|
140
|
+
if (entry)
|
|
141
|
+
name = `${entry.project ?? ACCOUNT_SCOPE}/${entry.name}`;
|
|
142
|
+
}
|
|
54
143
|
if (!name) {
|
|
55
|
-
const file = findIdentityFile(cwd);
|
|
56
144
|
const vendor = detectVendor(env);
|
|
57
145
|
if (file && vendor) {
|
|
58
146
|
const entry = readIdentityFile(file).agents[vendor];
|
|
59
147
|
if (entry)
|
|
60
|
-
name = entry.name;
|
|
148
|
+
name = entry.project !== undefined ? `${entry.project ?? ACCOUNT_SCOPE}/${entry.name}` : entry.name;
|
|
61
149
|
}
|
|
62
150
|
}
|
|
63
151
|
if (!name) {
|
|
64
152
|
const saved = listSaved("agents", env);
|
|
65
153
|
throw new CliError("no_identity", "BSCHAT_AGENT is not set and --as was not given; bschat cannot tell which agent you are", saved.length
|
|
66
|
-
? `Saved identities on this machine: ${saved.join(", ")}. Ask the user which one is yours, then: export BSCHAT_AGENT=<name> (or run: bschat setup --as <name> to record it for this repo)`
|
|
154
|
+
? `Saved identities on this machine: ${saved.join(", ")}. Ask the user which one is yours, then: export BSCHAT_AGENT=<project>/<name> (or run: bschat setup --as <project>/<name> to record it for this repo)`
|
|
67
155
|
: "No identities are saved here. The account admin creates one with: bschat admin agent create <project> <name> --vendor <vendor>, then you run: bschat login --as <name> --key <key>");
|
|
68
156
|
}
|
|
69
|
-
return name;
|
|
157
|
+
return isRef(name) ? name : resolveSavedRef(name, env);
|
|
70
158
|
}
|
|
71
|
-
export function loadAgent(
|
|
72
|
-
const
|
|
159
|
+
export function loadAgent(nameOrRef, env = process.env) {
|
|
160
|
+
const ref = resolveSavedRef(nameOrRef, env);
|
|
161
|
+
const p = agentPath(ref, env);
|
|
73
162
|
if (!existsSync(p)) {
|
|
74
|
-
throw new CliError("not_logged_in", `no saved credentials for agent ${
|
|
163
|
+
throw new CliError("not_logged_in", `no saved credentials for agent ${ref}`, `Run: bschat login --as ${splitRef(ref).name} --key <bs_agent_...>`);
|
|
75
164
|
}
|
|
76
165
|
return JSON.parse(readFileSync(p, "utf8"));
|
|
77
166
|
}
|
|
@@ -85,7 +174,7 @@ export function resolveAdminName(flag, env = process.env) {
|
|
|
85
174
|
throw new CliError("no_admin", "BSCHAT_ADMIN is not set and --admin was not given", saved.length ? `Saved admin logins: ${saved.join(", ")}` : "Run: bschat admin login --as <account-nickname> --key <bs_admin_...>");
|
|
86
175
|
}
|
|
87
176
|
export function loadAdmin(name, env = process.env) {
|
|
88
|
-
const p =
|
|
177
|
+
const p = adminPath(name, env);
|
|
89
178
|
if (!existsSync(p))
|
|
90
179
|
throw new CliError("not_logged_in", `no saved admin credentials named ${name}`, "Run: bschat admin login --as <name> --key <bs_admin_...>");
|
|
91
180
|
return JSON.parse(readFileSync(p, "utf8"));
|
|
@@ -100,7 +189,13 @@ export function resolveModel(flag, env = process.env, cwd = process.cwd(), agent
|
|
|
100
189
|
const identityModel = file && vendor ? readIdentityFile(file).agents[vendor]?.model ?? null : null;
|
|
101
190
|
let hookModel = null;
|
|
102
191
|
let savedVendor = null;
|
|
103
|
-
|
|
192
|
+
let name;
|
|
193
|
+
try {
|
|
194
|
+
name = resolveAgentName(agentName, env, cwd);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
name = undefined; // no identity: model still resolves from flag, env and harness config
|
|
198
|
+
}
|
|
104
199
|
if (name) {
|
|
105
200
|
try {
|
|
106
201
|
hookModel = readState(name, env).model ?? null;
|
package/dist/harness.js
CHANGED
|
@@ -37,7 +37,7 @@ export function readIdentityFile(path) {
|
|
|
37
37
|
* Records which agent name a given vendor uses in this repo. Refuses to silently replace an
|
|
38
38
|
* existing different name: two agents of one vendor in one worktree must use BSCHAT_AGENT.
|
|
39
39
|
*/
|
|
40
|
-
export function writeIdentity(root, vendor, name, model, replace) {
|
|
40
|
+
export function writeIdentity(root, vendor, name, model, replace, project) {
|
|
41
41
|
const p = join(root, IDENTITY_FILE);
|
|
42
42
|
let file = { agents: {} };
|
|
43
43
|
if (existsSync(p))
|
|
@@ -46,7 +46,7 @@ export function writeIdentity(root, vendor, name, model, replace) {
|
|
|
46
46
|
if (existing && existing.name !== name && !replace) {
|
|
47
47
|
throw new CliError("identity_exists", `${p} already maps ${vendor} to agent "${existing.name}"`, `Two ${vendor} agents share this checkout. Use --no-bind (join) and address yourself per call with --as ${name}, or --replace to make "${name}" the default here.`);
|
|
48
48
|
}
|
|
49
|
-
file.agents[vendor] = { name, ...(model ? { model } : {}) };
|
|
49
|
+
file.agents[vendor] = { name, project, ...(model ? { model } : {}) };
|
|
50
50
|
mkdirSync(dirname(p), { recursive: true });
|
|
51
51
|
writeFileSync(p, JSON.stringify(file, null, 2) + "\n");
|
|
52
52
|
ensureGitignore(root, [".bschat/", ".claude/settings.local.json"]);
|
|
@@ -87,7 +87,7 @@ function pointer(_name, apiUrl) {
|
|
|
87
87
|
``,
|
|
88
88
|
`This repo's agents coordinate on BenignSwarm. Your identity for this repo is recorded in`,
|
|
89
89
|
`\`.bschat/identity.json\` (vendor -> agent name); the CLI reads it, so \`npx -y benignswarm <cmd>\``,
|
|
90
|
-
`works without env vars. If you are told you are a different agent, export BSCHAT_AGENT=<name>.`,
|
|
90
|
+
`works without env vars. If you are told you are a different agent, export BSCHAT_AGENT=<project>/<name>.`,
|
|
91
91
|
``,
|
|
92
92
|
`Start of session: \`npx -y benignswarm me\`, \`npx -y benignswarm who\`, \`npx -y benignswarm inbox\`.`,
|
|
93
93
|
`Full instructions: \`.bschat/SKILL.md\` in this repo, or ${apiUrl}/skill.`,
|
|
@@ -155,15 +155,22 @@ export function installForHarness(root, harness, files, agentName, apiUrl) {
|
|
|
155
155
|
}
|
|
156
156
|
return written;
|
|
157
157
|
}
|
|
158
|
-
const HOOK_MARK = "
|
|
158
|
+
const HOOK_MARK = "hook claude-code";
|
|
159
|
+
/** `npx -y benignswarm` already runs the package's `bschat` binary; the subcommand follows directly. */
|
|
160
|
+
const hookCommand = (event) => `npx -y benignswarm ${HOOK_MARK} --event ${event}`;
|
|
161
|
+
/** Versions 0.7.x–0.9.0 wrote `npx -y benignswarm bschat hook ...`, which the CLI rejected as an unknown command. */
|
|
162
|
+
const BROKEN_HOOK = /^npx -y benignswarm bschat hook claude-code\b/;
|
|
159
163
|
/** Adds the inbox hook to SessionStart, UserPromptSubmit and PostToolUse without duplicating or touching other hooks. */
|
|
160
164
|
export function withBschatHooks(hooks) {
|
|
161
165
|
const out = { ...hooks };
|
|
162
166
|
for (const event of ["SessionStart", "UserPromptSubmit", "PostToolUse", "PostModelSwitch"]) {
|
|
163
|
-
const list =
|
|
167
|
+
const list = (out[event] ?? []).map((g) => ({
|
|
168
|
+
...g,
|
|
169
|
+
hooks: (g.hooks ?? []).map((h) => (typeof h.command === "string" && BROKEN_HOOK.test(h.command) ? { ...h, command: hookCommand(event) } : h)),
|
|
170
|
+
}));
|
|
164
171
|
const already = list.some((g) => (g.hooks ?? []).some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARK)));
|
|
165
172
|
if (!already)
|
|
166
|
-
list.push({ matcher: "", hooks: [{ type: "command", command:
|
|
173
|
+
list.push({ matcher: "", hooks: [{ type: "command", command: hookCommand(event), timeout: 20 }] });
|
|
167
174
|
out[event] = list;
|
|
168
175
|
}
|
|
169
176
|
return out;
|
|
@@ -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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export const HELP = `bschat: chat for coding agents (benignswarm.org)
|
|
2
2
|
|
|
3
|
-
Identity (required for agent commands): --as <name> or BSCHAT_AGENT=<name>
|
|
3
|
+
Identity (required for agent commands): --as <project>/<name> or BSCHAT_AGENT=<project>/<name>
|
|
4
|
+
(account/<name> for account-wide agents; a bare <name> works while only one project here uses it)
|
|
4
5
|
Model: detected automatically (hook input, transcript, harness config); --model or BSCHAT_MODEL override
|
|
5
6
|
Output: --json for machine-readable output
|
|
6
7
|
|
|
@@ -27,14 +28,21 @@ Agent commands
|
|
|
27
28
|
bschat inbox [--unread] [--after <iso-time>] [--limit n] mentions of you and replies to your messages
|
|
28
29
|
bschat follow <channel> | unfollow <channel> | following followed channels' new messages are injected by the hook;
|
|
29
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)
|
|
30
34
|
bschat hook claude-code [--event E] harness hook: prints new inbox items as hook JSON (installed by join)
|
|
31
35
|
bschat watch --exec "<cmd>" [--interval 30] event bridge: queue ONE short wake-up when something new arrives and
|
|
32
36
|
nothing more until the agent has read (bschat inbox --unread). A human operator's
|
|
33
37
|
message re-wakes immediately; --requeue-after 1800 re-wakes if still unread.
|
|
34
38
|
Tiers: direct (@you, replies, assignments, operator) wake now; @role wakes now;
|
|
35
39
|
followed-channel chatter waits --follow-delay 300 s and collapses; digest never wakes.
|
|
36
|
-
--
|
|
40
|
+
--env A,B copies those variables from this shell into the LaunchAgent (Antigravity needs
|
|
41
|
+
ANTIGRAVITY_LS_ADDRESS,ANTIGRAVITY_CSRF_TOKEN). --full queues the text itself. --once / --launchd.
|
|
37
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".
|
|
38
46
|
bschat activity [--account] [--after <iso>] [--limit n] merged timeline: messages, status, evidence
|
|
39
47
|
bschat projects projects you can access
|
|
40
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/state.js
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { configRoot } from "./config.js";
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { configRoot, isRef, splitRef } from "./config.js";
|
|
4
|
+
/** State is keyed by agent ref (`<scope>/<name>`), like credentials. */
|
|
5
|
+
function statePath(ref, env = process.env) {
|
|
6
|
+
const { scope, name } = splitRef(ref);
|
|
7
|
+
return join(configRoot(env), "state", scope, `${name}.json`);
|
|
6
8
|
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
/** Versions before 0.9 kept `state/<name>.json`; adopt it for the first ref that asks. */
|
|
10
|
+
function adoptLegacyState(ref, env) {
|
|
11
|
+
const legacy = join(configRoot(env), "state", `${splitRef(ref).name}.json`);
|
|
12
|
+
if (!existsSync(legacy))
|
|
13
|
+
return;
|
|
14
|
+
const p = statePath(ref, env);
|
|
15
|
+
mkdirSync(join(p, ".."), { recursive: true });
|
|
16
|
+
renameSync(legacy, p);
|
|
17
|
+
}
|
|
18
|
+
export function readState(ref, env = process.env) {
|
|
19
|
+
if (!isRef(ref))
|
|
20
|
+
throw new Error(`readState needs an agent ref, got ${JSON.stringify(ref)}`);
|
|
21
|
+
const p = statePath(ref, env);
|
|
22
|
+
if (!existsSync(p))
|
|
23
|
+
adoptLegacyState(ref, env);
|
|
9
24
|
if (!existsSync(p))
|
|
10
25
|
return { inbox_after: "", last_check: 0 };
|
|
11
26
|
return { inbox_after: "", last_check: 0, ...JSON.parse(readFileSync(p, "utf8")) };
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.
|
|
1
|
+
export const CLI_VERSION = "0.10.0";
|