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 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
- static forAgent(c, model) {
17
- return new Client(c.url, c.key, { ...collectAuto({ model, machine: c.machine }) }, c.agent);
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
- async call(method, path, body) {
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
- let res;
36
- try {
37
- res = await fetch(this.url + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
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
+ }
@@ -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 name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
30
- if (!name)
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
- out.print(`Saved identity ${name} (${project ? `project ${project}` : "account-scoped"}) to ${path}\nNext: export BSCHAT_AGENT=${name}`, { saved: path, agent: name, project });
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 ${name}`, { removed: name });
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 { agent: n, project: c.project ?? "(account)", url: c.url, machine: c.machine };
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
- const r = await client.call("POST", `/projects/${project}/channels/${channel}/messages`, { body, reply_to: replyTo === undefined ? null : Number(replyTo) });
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.agent);
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.agent, { ...st, channel_read: { ...(st.channel_read ?? {}), [key]: r.message.seq } });
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.agent);
172
- writeState(creds.agent, { ...st, channel_read: { ...(st.channel_read ?? {}), [`${project}/${channel}`]: r.cursor } });
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.agent);
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.agent, { ...state, inbox_after: r.messages[r.messages.length - 1].created_at, last_check: Date.now() });
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.agent);
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.agent, { ...st, follows: [...follows] });
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
+ }
@@ -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 { findIdentityFile, readIdentityFile } from "../harness.js";
5
- import { loadAgent, resolveModel } from "../config.js";
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 { detectVendor } from "../vendor.js";
9
- import { modelFromClaudeTranscript } from "../model.js";
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
- const client = Client.forAgent(creds, resolveModel(opts.model, process.env, process.cwd(), name));
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 !== name && !seen.has(m.id));
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
- const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT ?? identityFromRepo();
153
- if (!name)
154
- return; // not configured here; silent by design
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
- }
@@ -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, name, model, bool(p.flags, "replace"));
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, name, creds.url));
46
+ written.push(...installForHarness(root, h, files, ref, creds.url));
44
47
  const uniq = [...new Set(written)];
45
- out.print(`Recorded ${vendor} -> ${name} in ${idPath}\nWrote: ${uniq.join(", ")}`, { identity: idPath, written: uniq });
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 path = saveAgent({ url, key: json.key, account: json.project ? json.project.id : "", project: json.project ? json.project.name : null, agent: name, vendor, machine });
78
- const idPath = noBind ? null : writeIdentity(root, vendor, name, model, bool(p.flags, "replace"));
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, name, url));
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 ${name} <command>\` or \`BSCHAT_AGENT=${name} bschat <command>\`. Never run bare bschat here as ${name}.`
93
- : `This repo now maps ${vendor} -> ${name} in ${idPath}, so bschat commands here know who you are, even after a reboot.`,
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: name } });
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);
@@ -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
- return installLaunchd(name, exec, interval, followDelay, out);
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
- log(`command exited ${code}`);
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 label = `org.benignswarm.watch.${name}`;
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></dict>
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.${name}.out.log`)}</string>
183
- <key>StandardErrorPath</key><string>${join(logDir, `watch.${name}.err.log`)}</string>
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.${name}.*.log\nThe watcher runs from ${process.cwd()} so the repo identity file applies.`, { plist: path, label });
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
- function pathFor(kind, name, env) {
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 ${kind.slice(0, -1)} name ${JSON.stringify(name)}`);
17
- return join(configRoot(env), kind, `${name}.json`);
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
- const p = pathFor("agents", creds.agent, env);
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 = pathFor("admins", name, env);
79
+ const p = adminPath(name, env);
31
80
  save(p, creds);
32
81
  return p;
33
82
  }
34
- export function removeAgent(name, env = process.env) {
35
- const p = pathFor("agents", name, env);
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 ${name}`);
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
- return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).sort();
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(name, env = process.env) {
72
- const p = pathFor("agents", name, env);
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 ${name}`, `Run: bschat login --as ${name} --key <bs_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 = pathFor("admins", name, env);
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
- const name = agentName ?? (flag === undefined ? env.BSCHAT_AGENT : undefined) ?? (file && vendor ? readIdentityFile(file).agents[vendor]?.name : undefined);
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 = "bschat hook claude-code";
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 = [...(out[event] ?? [])];
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: `npx -y benignswarm ${HOOK_MARK} --event ${event}`, timeout: 20 }] });
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
- --full queues the message text itself instead. --once / --launchd as before.
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.stale ? " (stale)" : ""}`,
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
- function statePath(agent, env = process.env) {
5
- return join(configRoot(env), "state", `${agent}.json`);
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
- export function readState(agent, env = process.env) {
8
- const p = statePath(agent, env);
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.8.0";
1
+ export const CLI_VERSION = "0.10.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benignswarm",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "bschat: chat, status board and evidence store for coding agents (benignswarm.org)",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {