benignswarm 0.8.0 → 0.9.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 = {};
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { bool, need, str } from "../args.js";
4
4
  import { Client } from "../client.js";
5
- import { DEFAULT_URL, listSaved, loadAgent, removeAgent, resolveAgentName, resolveModel, saveAgent } from "../config.js";
5
+ import { agentRef, DEFAULT_URL, listSaved, loadAgent, removeAgent, resolveAgentName, resolveModel, saveAgent } from "../config.js";
6
6
  import { CliError } from "../errors.js";
7
7
  import { defaultMachine } from "../git.js";
8
8
  import { fmtMessage, fmtStatus, table } from "../output.js";
@@ -10,7 +10,7 @@ import { readState, writeState } from "../state.js";
10
10
  function agentClient(p) {
11
11
  const name = resolveAgentName(str(p.flags, "as"));
12
12
  const creds = loadAgent(name);
13
- const model = resolveModel(str(p.flags, "model"));
13
+ const model = resolveModel(str(p.flags, "model"), process.env, process.cwd(), name);
14
14
  return { client: Client.forAgent(creds, model), creds };
15
15
  }
16
16
  /**
@@ -26,9 +26,10 @@ export function projectOf(p, creds) {
26
26
  return v;
27
27
  }
28
28
  export async function login(p, out) {
29
- const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
30
- if (!name)
29
+ const asFlag = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
30
+ if (!asFlag)
31
31
  throw new CliError("usage", "login needs --as <agent-name>");
32
+ const name = asFlag.includes("/") ? asFlag.slice(asFlag.indexOf("/") + 1) : asFlag;
32
33
  const key = need(p.flags, "key");
33
34
  if (!key.startsWith("bs_agent_"))
34
35
  throw new CliError("usage", "login expects an agent key (bs_agent_...). For admin keys use: bschat admin login");
@@ -41,23 +42,24 @@ export async function login(p, out) {
41
42
  }
42
43
  const project = me.project ? me.project.name : null;
43
44
  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 });
45
+ const ref = agentRef({ project, agent: name });
46
+ out.print(`Saved identity ${ref} (${project ? `project ${project}` : "account-scoped"}) to ${path}\nNext: export BSCHAT_AGENT=${ref}`, { saved: path, agent: name, project, ref });
45
47
  }
46
48
  export async function logout(p, out) {
47
49
  const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
48
50
  if (!name)
49
51
  throw new CliError("usage", "logout needs --as <agent-name>");
50
- removeAgent(name);
51
- out.print(`Removed saved identity ${name}`, { removed: name });
52
+ const ref = removeAgent(name);
53
+ out.print(`Removed saved identity ${ref}`, { removed: ref });
52
54
  }
53
55
  export async function identities(_p, out) {
54
56
  const names = listSaved("agents");
55
57
  const rows = names.map((n) => {
56
58
  const c = loadAgent(n);
57
- return { agent: n, project: c.project ?? "(account)", url: c.url, machine: c.machine };
59
+ return { ref: n, agent: c.agent, project: c.project ?? "(account)", url: c.url, machine: c.machine };
58
60
  });
59
61
  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 });
62
+ out.print(`${table(rows, ["ref", "agent", "project", "url", "machine"])}\n\ncurrent: ${current}`, { identities: rows, current: process.env.BSCHAT_AGENT ?? null });
61
63
  }
62
64
  export async function me(p, out) {
63
65
  const { client } = agentClient(p);
@@ -143,11 +145,11 @@ export async function send(p, out) {
143
145
  const r = await client.call("POST", `/projects/${project}/channels/${channel}/messages`, { body, reply_to: replyTo === undefined ? null : Number(replyTo) });
144
146
  {
145
147
  // Our own post must not count as unread for us; advance the cursor if we were caught up.
146
- const st = readState(creds.agent);
148
+ const st = readState(agentRef(creds));
147
149
  const key = `${project}/${channel}`;
148
150
  const last = st.channel_read?.[key];
149
151
  if (last !== undefined && last >= r.message.seq - 1)
150
- writeState(creds.agent, { ...st, channel_read: { ...(st.channel_read ?? {}), [key]: r.message.seq } });
152
+ writeState(agentRef(creds), { ...st, channel_read: { ...(st.channel_read ?? {}), [key]: r.message.seq } });
151
153
  }
152
154
  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
155
  }
@@ -168,8 +170,8 @@ export async function read(p, out) {
168
170
  qs.set("limit", str(p.flags, "limit") ?? "100");
169
171
  const r = await client.call("GET", `/projects/${project}/channels/${channel}/messages?${qs}`);
170
172
  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 } });
173
+ const st = readState(agentRef(creds));
174
+ writeState(agentRef(creds), { ...st, channel_read: { ...(st.channel_read ?? {}), [`${project}/${channel}`]: r.cursor } });
173
175
  out.print(`${text}\ncursor: ${r.cursor}`, r);
174
176
  }
175
177
  function follow(client, path, out) {
@@ -203,14 +205,14 @@ export async function inbox(p, out) {
203
205
  const { client, creds } = agentClient(p);
204
206
  const qs = new URLSearchParams();
205
207
  const unread = bool(p.flags, "unread");
206
- const state = readState(creds.agent);
208
+ const state = readState(agentRef(creds));
207
209
  const after = str(p.flags, "after") ?? (unread ? state.inbox_after : undefined);
208
210
  if (after)
209
211
  qs.set("after", after);
210
212
  qs.set("limit", str(p.flags, "limit") ?? "50");
211
213
  const r = await client.call("GET", `/inbox?${qs}`);
212
214
  if (unread && r.messages.length)
213
- writeState(creds.agent, { ...state, inbox_after: r.messages[r.messages.length - 1].created_at, last_check: Date.now() });
215
+ writeState(agentRef(creds), { ...state, inbox_after: r.messages[r.messages.length - 1].created_at, last_check: Date.now() });
214
216
  const fmt = (m) => `${creds.project ? "" : `[${m.project}] `}${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`;
215
217
  const items = (r.items ?? []).map((i) => `list "${i.list}": [${i.status}] ${i.title} (item ${i.id})`);
216
218
  const text = [...r.messages.map(fmt), ...(items.length ? ["assigned to you:", ...items] : [])].join("\n");
@@ -294,7 +296,7 @@ export async function followCmd(p, out) {
294
296
  const channel = p.positionals[1];
295
297
  const { creds, client } = agentClient(p);
296
298
  const unfollow = p.positionals[0] === "unfollow";
297
- const st = readState(creds.agent);
299
+ const st = readState(agentRef(creds));
298
300
  const follows = new Set(st.follows ?? []);
299
301
  if (p.positionals[0] === "following" || !channel) {
300
302
  out.print(follows.size ? [...follows].join("\n") : "(following no channels; the hook shows a digest line only)", { follows: [...follows] });
@@ -309,6 +311,6 @@ export async function followCmd(p, out) {
309
311
  await client.call("GET", `/projects/${project}/channels/${channel}/messages?limit=1`); // validates access
310
312
  follows.add(key);
311
313
  }
312
- writeState(creds.agent, { ...st, follows: [...follows] });
314
+ writeState(agentRef(creds), { ...st, follows: [...follows] });
313
315
  out.print(`${unfollow ? "unfollowed" : "following"} #${channel} in ${project}`, { follows: [...follows] });
314
316
  }
@@ -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];
@@ -78,7 +78,7 @@ export async function gatherUpdates(name, opts) {
78
78
  if (follows.has(key)) {
79
79
  const r = await client.call("GET", `/projects/${project}/channels/${ch.name}/messages?since=${last}&limit=5`);
80
80
  const seen = new Set(messages.map((m) => m.id));
81
- const others = r.messages.filter((m) => m.agent_name !== name && !seen.has(m.id));
81
+ const others = r.messages.filter((m) => m.agent_name !== creds.agent && !seen.has(m.id));
82
82
  for (const m of others)
83
83
  followed.push(`${creds.project ? "" : `[${project}] `}${fmtMessage(m)}`);
84
84
  const shown = r.messages.length ? r.messages[r.messages.length - 1].seq : last;
@@ -1,7 +1,7 @@
1
1
  import { CLI_VERSION } from "../version.js";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { need, str, bool } from "../args.js";
4
- import { DEFAULT_URL, loadAgent, resolveModel, saveAgent } from "../config.js";
4
+ import { agentRef, DEFAULT_URL, loadAgent, resolveModel, saveAgent } from "../config.js";
5
5
  import { CliError } from "../errors.js";
6
6
  import { defaultMachine } from "../git.js";
7
7
  import { HARNESSES, findIdentityFile, installForHarness, normalizeVendor, readIdentityFile, writeIdentity } from "../harness.js";
@@ -33,21 +33,24 @@ export async function setup(p, out) {
33
33
  if (!name)
34
34
  throw new CliError("usage", "setup needs --as <agent-name> (an identity already saved with join or login)");
35
35
  const creds = loadAgent(name);
36
+ const ref = agentRef(creds);
36
37
  const vendor = normalizeVendor(str(p.flags, "vendor") ?? creds.vendor);
37
38
  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"));
39
+ const model = resolveModel(str(p.flags, "model"), process.env, process.cwd(), ref);
40
+ const idPath = writeIdentity(root, vendor, creds.agent, model, bool(p.flags, "replace"), creds.project);
40
41
  const files = await fetchSkill(creds.url);
41
42
  const written = [];
42
43
  for (const h of harnessList(p, vendor))
43
- written.push(...installForHarness(root, h, files, name, creds.url));
44
+ written.push(...installForHarness(root, h, files, ref, creds.url));
44
45
  const uniq = [...new Set(written)];
45
- out.print(`Recorded ${vendor} -> ${name} in ${idPath}\nWrote: ${uniq.join(", ")}`, { identity: idPath, written: uniq });
46
+ out.print(`Recorded ${vendor} -> ${ref} in ${idPath}\nWrote: ${uniq.join(", ")}`, { identity: idPath, written: uniq });
46
47
  }
47
48
  export async function join(p, out) {
48
49
  const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
49
50
  if (!name)
50
51
  throw new CliError("usage", "join needs --as <agent-name> (the name you were given)");
52
+ if (name.includes("/"))
53
+ throw new CliError("usage", "join takes the bare agent name; the project comes from the token");
51
54
  const token = need(p.flags, "token");
52
55
  if (!token.startsWith("bs_enroll_"))
53
56
  throw new CliError("usage", "--token must be an enrollment token (bs_enroll_...)");
@@ -74,13 +77,15 @@ export async function join(p, out) {
74
77
  const json = await res.json();
75
78
  if (!res.ok)
76
79
  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"));
80
+ const project = json.project ? json.project.name : null;
81
+ const ref = agentRef({ project, agent: name });
82
+ const path = saveAgent({ url, key: json.key, account: json.project ? json.project.id : "", project, agent: name, vendor, machine });
83
+ const idPath = noBind ? null : writeIdentity(root, vendor, name, model, bool(p.flags, "replace"), project);
79
84
  let written = [];
80
85
  if (!bool(p.flags, "no-skill") && !noBind) {
81
86
  const files = await fetchSkill(url);
82
87
  for (const h of harnessList(p, vendor))
83
- written.push(...installForHarness(root, h, files, name, url));
88
+ written.push(...installForHarness(root, h, files, ref, url));
84
89
  written = [...new Set(written)];
85
90
  }
86
91
  const human = [
@@ -89,8 +94,8 @@ export async function join(p, out) {
89
94
  : `Joined as account-wide agent "${name}" (${vendor}) with role ${json.agent.role_name}. Project commands take --project <name>; see them with: bschat me`,
90
95
  `Credentials saved to ${path} (private to this machine).`,
91
96
  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.`,
97
+ ? `This repo's ${vendor} identity was left as is. Address yourself on every call: \`bschat --as ${ref} <command>\` or \`BSCHAT_AGENT=${ref} bschat <command>\`. Never run bare bschat here as ${name}.`
98
+ : `This repo now maps ${vendor} -> ${ref} in ${idPath}, so bschat commands here know who you are, even after a reboot.`,
94
99
  written.length ? `Harness files written: ${written.join(", ")}` : null,
95
100
  ``,
96
101
  `Your model is detected automatically (hook, transcript or harness config); override with --model or BSCHAT_MODEL only if it reports "unknown".`,
@@ -99,9 +104,10 @@ export async function join(p, out) {
99
104
  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
105
  json.revived && json.resume ? `Read it before doing anything else.` : null,
101
106
  ``,
107
+ 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
108
  `Next: bschat me && bschat who && bschat inbox && bschat handoffs --open`,
103
109
  ].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 } });
110
+ 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 } });
105
111
  }
106
112
  function writeIdentityDryRun(root, vendor, name, replace) {
107
113
  const p = findIdentityFile(root);
@@ -32,7 +32,15 @@ export async function watch(p, out) {
32
32
  if (bool(p.flags, "launchd")) {
33
33
  if (!exec)
34
34
  throw new CliError("usage", "--launchd needs --exec so the agent knows what to run");
35
- return installLaunchd(name, exec, interval, followDelay, out);
35
+ const envNames = (str(p.flags, "env") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
36
+ const extraEnv = {};
37
+ for (const n of envNames) {
38
+ const v = process.env[n];
39
+ if (v === undefined)
40
+ throw new CliError("usage", `--env ${n}: not set in this shell; run this from a terminal inside the harness (Antigravity's terminal has ANTIGRAVITY_LS_ADDRESS and ANTIGRAVITY_CSRF_TOKEN)`);
41
+ extraEnv[n] = v;
42
+ }
43
+ return installLaunchd(name, exec, interval, followDelay, extraEnv, out);
36
44
  }
37
45
  if (!exec && !once)
38
46
  throw new CliError("usage", 'watch --exec "<command>" [--interval 30] | watch --once (print and exit) | watch --exec ... --launchd');
@@ -129,8 +137,11 @@ async function wakeOnce(name, exec, requeueAfter, followDelay, log, model, once)
129
137
  const cmd = exec.replaceAll("{count}", String(total)).replaceAll("{summary}", shellQuote(summary)).replaceAll("{text}", shellQuote(wake));
130
138
  log(`${operatorNew ? "operator message; " : stale ? "re-waking after timeout; " : ""}[${u.tier}] queueing wake-up: ${summary}`);
131
139
  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}`);
140
+ if (code !== 0) {
141
+ // The agent was NOT woken. Leave nothing pending so the next poll tries again.
142
+ log(`wake command exited ${code}; will retry next poll. If this repeats, the harness needs environment the LaunchAgent lacks (see --env).`);
143
+ return;
144
+ }
134
145
  }
135
146
  else {
136
147
  process.stdout.write(wake + "\n");
@@ -149,10 +160,11 @@ function run(cmd, stdin, env) {
149
160
  });
150
161
  }
151
162
  /** Writes a launchd agent that keeps `bschat watch` running for this identity on macOS. */
152
- function installLaunchd(name, exec, interval, followDelay, out) {
163
+ function installLaunchd(name, exec, interval, followDelay, extraEnv, out) {
153
164
  if (process.platform !== "darwin")
154
165
  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}`;
166
+ const slug = name.replace("/", ".");
167
+ const label = `org.benignswarm.watch.${slug}`;
156
168
  const dir = join(homedir(), "Library", "LaunchAgents");
157
169
  const logDir = join(homedir(), ".bschat", "logs");
158
170
  mkdirSync(dir, { recursive: true });
@@ -175,17 +187,17 @@ function installLaunchd(name, exec, interval, followDelay, out) {
175
187
  <string>--interval</string><string>${interval}</string>
176
188
  <string>--follow-delay</string><string>${followDelay}</string>
177
189
  </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>
190
+ <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
191
  <key>WorkingDirectory</key><string>${escapeXml(process.cwd())}</string>
180
192
  <key>RunAtLoad</key><true/>
181
193
  <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>
194
+ <key>StandardOutPath</key><string>${join(logDir, `watch.${slug}.out.log`)}</string>
195
+ <key>StandardErrorPath</key><string>${join(logDir, `watch.${slug}.err.log`)}</string>
184
196
  </dict></plist>
185
197
  `;
186
198
  const path = join(dir, `${label}.plist`);
187
199
  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 });
200
+ 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
201
  }
190
202
  function escapeXml(s) {
191
203
  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;
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
 
@@ -33,7 +34,8 @@ Agent commands
33
34
  message re-wakes immediately; --requeue-after 1800 re-wakes if still unread.
34
35
  Tiers: direct (@you, replies, assignments, operator) wake now; @role wakes now;
35
36
  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.
37
+ --env A,B copies those variables from this shell into the LaunchAgent (Antigravity needs
38
+ ANTIGRAVITY_LS_ADDRESS,ANTIGRAVITY_CSRF_TOKEN). --full queues the text itself. --once / --launchd.
37
39
  Codex: bschat watch --exec "codex queue --thread <id> --message {summary}" --launchd
38
40
  bschat activity [--account] [--after <iso>] [--limit n] merged timeline: messages, status, evidence
39
41
  bschat projects projects you can access
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.9.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benignswarm",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "bschat: chat, status board and evidence store for coding agents (benignswarm.org)",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {