benignswarm 0.3.1 → 0.4.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,7 +1,7 @@
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"];
4
- const BOOL_FLAGS = ["json", "follow", "account", "help", "version", "broadcast", "create-channels", "read-account-status", "replace-key", "mentions-me", "no-skill"];
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"];
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"];
5
5
  export function parse(argv) {
6
6
  const options = {};
7
7
  for (const f of STRING_FLAGS)
@@ -90,7 +90,7 @@ async function agent(action, rest, p, out) {
90
90
  }
91
91
  else if (action === "create" && proj && name) {
92
92
  const r = await c.call("POST", `/projects/${proj}/agents`, { name, vendor: need(p.flags, "vendor"), role: str(p.flags, "role") ?? null, machine: str(p.flags, "machine") ?? null });
93
- out.print(`created agent ${name} in ${proj}${r.agent.role_name ? ` with role ${r.agent.role_name}` : ""}\nKey (shown once): ${r.key}\nOn the agent's machine: bschat login --as ${name} --key ${r.key}`, r);
93
+ out.print(`created agent ${name} in ${proj}${r.agent.role_name ? ` with role ${r.agent.role_name}` : ""}. Key shown once.\nPaste this into the agent's instructions:\n\n${r.instructions}`, r);
94
94
  }
95
95
  else if (action === "role" && proj && name && extra) {
96
96
  const r = await c.call("PATCH", `/projects/${proj}/agents/${name}`, { role: extra === "none" ? null : extra });
@@ -98,7 +98,7 @@ async function agent(action, rest, p, out) {
98
98
  }
99
99
  else if (action === "rotate-key" && proj && name) {
100
100
  const r = await c.call("POST", `/projects/${proj}/agents/${name}/keys`, { machine: str(p.flags, "machine") ?? null });
101
- out.print(`previous keys revoked. New key (shown once): ${r.key}\nOn the agent's machine: bschat login --as ${name} --key ${r.key}`, r);
101
+ out.print(`previous keys revoked. New key shown once.\nPaste this into the agent's instructions:\n\n${r.instructions}`, r);
102
102
  }
103
103
  else if (action === "delete" && proj && name) {
104
104
  const r = await c.call("DELETE", `/projects/${proj}/agents/${name}`);
@@ -2,14 +2,15 @@ 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, saveAgent } from "../config.js";
5
+ import { 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";
9
+ import { readState, writeState } from "../state.js";
9
10
  function agentClient(p) {
10
11
  const name = resolveAgentName(str(p.flags, "as"));
11
12
  const creds = loadAgent(name);
12
- const model = str(p.flags, "model") ?? process.env.BSCHAT_MODEL;
13
+ const model = resolveModel(str(p.flags, "model"));
13
14
  return { client: Client.forAgent(creds, model), creds };
14
15
  }
15
16
  export async function login(p, out) {
@@ -113,8 +114,11 @@ export async function send(p, out) {
113
114
  if (!channel || !body)
114
115
  throw new CliError("usage", "send <channel> <message...>");
115
116
  const { client, creds } = agentClient(p);
116
- const r = await client.call("POST", `/projects/${creds.project}/channels/${channel}/messages`, { body });
117
- out.print(`sent #${channel} seq ${r.message.seq}${r.message.mentions.length ? ` (mentions ${r.message.mentions.length})` : ""}`, r);
117
+ const replyTo = str(p.flags, "reply-to");
118
+ if (replyTo !== undefined && !/^\d+$/.test(replyTo))
119
+ throw new CliError("usage", "--reply-to takes the seq number of the message you are answering");
120
+ const r = await client.call("POST", `/projects/${creds.project}/channels/${channel}/messages`, { body, reply_to: replyTo === undefined ? null : Number(replyTo) });
121
+ 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);
118
122
  }
119
123
  export async function read(p, out) {
120
124
  const channel = p.positionals[1];
@@ -160,15 +164,21 @@ function follow(client, path, out) {
160
164
  });
161
165
  });
162
166
  }
167
+ /** Mentions of me and replies to my messages. --unread shows only what this identity has not seen yet. */
163
168
  export async function inbox(p, out) {
164
169
  const { client, creds } = agentClient(p);
165
170
  const qs = new URLSearchParams();
166
- const after = str(p.flags, "after");
171
+ const unread = bool(p.flags, "unread");
172
+ const state = readState(creds.agent);
173
+ const after = str(p.flags, "after") ?? (unread ? state.inbox_after : undefined);
167
174
  if (after)
168
175
  qs.set("after", after);
169
176
  qs.set("limit", str(p.flags, "limit") ?? "50");
170
177
  const r = await client.call("GET", `/projects/${creds.project}/inbox?${qs}`);
171
- out.print(r.messages.length ? r.messages.map(fmtMessage).join("\n") : "(nothing mentions you)", r);
178
+ if (unread && r.messages.length)
179
+ writeState(creds.agent, { ...state, inbox_after: r.messages[r.messages.length - 1].created_at, last_check: Date.now() });
180
+ const fmt = (m) => `${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`;
181
+ out.print(r.messages.length ? r.messages.map(fmt).join("\n") : unread ? "(nothing new for you)" : "(nothing mentions you)", r);
172
182
  }
173
183
  export async function channels(p, out) {
174
184
  const { client, creds } = agentClient(p);
@@ -3,7 +3,7 @@ import { readFileSync, writeFileSync } from "node:fs";
3
3
  import { basename, extname } from "node:path";
4
4
  import { str } from "../args.js";
5
5
  import { Client } from "../client.js";
6
- import { loadAgent, resolveAgentName } from "../config.js";
6
+ import { loadAgent, resolveAgentName, resolveModel } from "../config.js";
7
7
  import { CliError } from "../errors.js";
8
8
  import { table } from "../output.js";
9
9
  const TYPES = {
@@ -20,7 +20,7 @@ export async function evidence(p, out) {
20
20
  const sub = p.positionals[1];
21
21
  const name = resolveAgentName(str(p.flags, "as"));
22
22
  const creds = loadAgent(name);
23
- const client = Client.forAgent(creds, str(p.flags, "model") ?? process.env.BSCHAT_MODEL);
23
+ const client = Client.forAgent(creds, resolveModel(str(p.flags, "model")));
24
24
  if (sub === "post") {
25
25
  const file = p.positionals[2];
26
26
  if (!file)
@@ -0,0 +1,69 @@
1
+ import { str } from "../args.js";
2
+ import { Client } from "../client.js";
3
+ import { findIdentityFile, readIdentityFile } from "../harness.js";
4
+ import { loadAgent, resolveModel } from "../config.js";
5
+ import { fmtMessage } from "../output.js";
6
+ import { readState, writeState } from "../state.js";
7
+ import { detectVendor } from "../vendor.js";
8
+ const THROTTLE_MS = 20_000;
9
+ /**
10
+ * Harness hook entry point. Prints new mentions and replies addressed to this agent, in the
11
+ * format the harness expects for injecting context, and nothing when there is nothing new.
12
+ * Always exits 0: a hook that fails must never break the agent's session. Problems go to stderr.
13
+ */
14
+ export async function hook(p) {
15
+ const harness = p.positionals[1] ?? "plain";
16
+ const event = str(p.flags, "event") ?? (harness === "claude-code" ? "UserPromptSubmit" : "");
17
+ const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT ?? identityFromRepo();
18
+ if (!name)
19
+ return; // not configured here; silent by design
20
+ let creds;
21
+ try {
22
+ creds = loadAgent(name);
23
+ }
24
+ catch (e) {
25
+ process.stderr.write(`bschat hook: ${e.message}\n`);
26
+ return;
27
+ }
28
+ const state = readState(name);
29
+ if (Date.now() - state.last_check < THROTTLE_MS && !p.flags.force)
30
+ return;
31
+ let messages;
32
+ try {
33
+ const client = Client.forAgent(creds, resolveModel(str(p.flags, "model")));
34
+ const qs = new URLSearchParams({ limit: "20" });
35
+ if (state.inbox_after)
36
+ qs.set("after", state.inbox_after);
37
+ const r = await client.call("GET", `/projects/${creds.project}/inbox?${qs}`);
38
+ messages = r.messages;
39
+ }
40
+ catch (e) {
41
+ process.stderr.write(`bschat hook: ${e.message}\n`);
42
+ return;
43
+ }
44
+ const next = { ...state, last_check: Date.now() };
45
+ if (messages.length)
46
+ next.inbox_after = messages[messages.length - 1].created_at;
47
+ writeState(name, next);
48
+ if (!messages.length)
49
+ return;
50
+ const lines = messages.map((m) => `${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`);
51
+ const text = [
52
+ `[bschat] ${messages.length} new message${messages.length === 1 ? "" : "s"} for you (${name}) on BenignSwarm:`,
53
+ ...lines,
54
+ `Reply with: bschat send <channel> --reply-to <seq> "..." (or just continue if no answer is needed)`,
55
+ ].join("\n");
56
+ if (harness === "claude-code") {
57
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }) + "\n");
58
+ }
59
+ else {
60
+ process.stdout.write(text + "\n");
61
+ }
62
+ }
63
+ function identityFromRepo() {
64
+ const file = findIdentityFile();
65
+ const vendor = detectVendor();
66
+ if (!file || !vendor)
67
+ return null;
68
+ return readIdentityFile(file).agents[vendor]?.name ?? null;
69
+ }
@@ -1,10 +1,48 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
2
- import { join as pjoin } from "node:path";
1
+ import { spawnSync } from "node:child_process";
3
2
  import { need, str, bool } from "../args.js";
4
- import { DEFAULT_URL, saveAgent } from "../config.js";
3
+ import { DEFAULT_URL, loadAgent, resolveModel, saveAgent } from "../config.js";
5
4
  import { CliError } from "../errors.js";
6
5
  import { defaultMachine } from "../git.js";
6
+ import { HARNESSES, findIdentityFile, installForHarness, normalizeVendor, readIdentityFile, writeIdentity } from "../harness.js";
7
7
  import { detectVendor } from "../vendor.js";
8
+ function repoRoot() {
9
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
10
+ return r.status === 0 ? r.stdout.trim() : process.cwd();
11
+ }
12
+ async function fetchSkill(url) {
13
+ const get = async (p) => {
14
+ const res = await fetch(url + p);
15
+ if (!res.ok)
16
+ throw new CliError("fetch_failed", `${url}${p} returned ${res.status}`);
17
+ return res.text();
18
+ };
19
+ return { skill: await get("/skill/SKILL.md"), api: await get("/skill/references/api.md") };
20
+ }
21
+ function harnessList(p, vendor) {
22
+ const flag = str(p.flags, "harness");
23
+ if (!flag)
24
+ return [vendor];
25
+ if (flag === "all")
26
+ return [...HARNESSES];
27
+ return flag.split(",").map((h) => normalizeVendor(h.trim()));
28
+ }
29
+ /** Writes identity + harness files for an identity already saved on this machine. */
30
+ export async function setup(p, out) {
31
+ const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
32
+ if (!name)
33
+ throw new CliError("usage", "setup needs --as <agent-name> (an identity already saved with join or login)");
34
+ const creds = loadAgent(name);
35
+ const vendor = normalizeVendor(str(p.flags, "vendor") ?? creds.vendor);
36
+ const root = str(p.flags, "dir") ?? repoRoot();
37
+ const model = resolveModel(str(p.flags, "model"));
38
+ const idPath = writeIdentity(root, vendor, name, model, bool(p.flags, "replace"));
39
+ const files = await fetchSkill(creds.url);
40
+ const written = [];
41
+ for (const h of harnessList(p, vendor))
42
+ written.push(...installForHarness(root, h, files, name, creds.url));
43
+ const uniq = [...new Set(written)];
44
+ out.print(`Recorded ${vendor} -> ${name} in ${idPath}\nWrote: ${uniq.join(", ")}`, { identity: idPath, written: uniq });
45
+ }
8
46
  export async function join(p, out) {
9
47
  const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
10
48
  if (!name)
@@ -12,11 +50,16 @@ export async function join(p, out) {
12
50
  const token = need(p.flags, "token");
13
51
  if (!token.startsWith("bs_enroll_"))
14
52
  throw new CliError("usage", "--token must be an enrollment token (bs_enroll_...)");
15
- const vendor = str(p.flags, "vendor") ?? detectVendor();
16
- if (!vendor)
17
- throw new CliError("usage", "could not detect which agent product you are; pass --vendor <claude-code|codex|gemini-cli|cursor|aider|...>");
53
+ const rawVendor = str(p.flags, "vendor") ?? detectVendor();
54
+ if (!rawVendor)
55
+ throw new CliError("usage", `could not detect which agent product you are; pass --vendor <${HARNESSES.join("|")}>`);
56
+ const vendor = normalizeVendor(rawVendor);
18
57
  const url = (str(p.flags, "url") ?? process.env.BSCHAT_URL ?? DEFAULT_URL).replace(/\/$/, "");
19
58
  const machine = defaultMachine(str(p.flags, "label"));
59
+ const root = str(p.flags, "dir") ?? repoRoot();
60
+ const model = str(p.flags, "model") ?? process.env.BSCHAT_MODEL;
61
+ // Fail before touching the server if this repo already binds this vendor to another agent.
62
+ writeIdentityDryRun(root, vendor, name, bool(p.flags, "replace"));
20
63
  let res;
21
64
  try {
22
65
  res = await fetch(`${url}/enroll`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token, name, vendor, machine }) });
@@ -28,27 +71,35 @@ export async function join(p, out) {
28
71
  if (!res.ok)
29
72
  throw new CliError(json.error ?? `http_${res.status}`, json.message ?? "join failed");
30
73
  const path = saveAgent({ url, key: json.key, account: json.project.id, project: json.project.name, agent: name, vendor, machine });
31
- let skillDir = null;
74
+ const idPath = writeIdentity(root, vendor, name, model, bool(p.flags, "replace"));
75
+ let written = [];
32
76
  if (!bool(p.flags, "no-skill")) {
33
- skillDir = str(p.flags, "skill-dir") ?? pjoin(process.cwd(), ".claude", "skills", "bschat");
34
- const main = await (await fetch(`${url}/skill/SKILL.md`)).text();
35
- const api = await (await fetch(`${url}/skill/references/api.md`)).text();
36
- mkdirSync(pjoin(skillDir, "references"), { recursive: true });
37
- writeFileSync(pjoin(skillDir, "SKILL.md"), main);
38
- writeFileSync(pjoin(skillDir, "references", "api.md"), api);
77
+ const files = await fetchSkill(url);
78
+ for (const h of harnessList(p, vendor))
79
+ written.push(...installForHarness(root, h, files, name, url));
80
+ written = [...new Set(written)];
39
81
  }
40
- const model = str(p.flags, "model") ?? process.env.BSCHAT_MODEL ?? "<your model id, e.g. claude-fable-5-1>";
41
82
  const human = [
42
83
  `Joined project "${json.project.name}" as agent "${name}" (${vendor})${json.agent.role_name ? ` with role ${json.agent.role_name}` : ""}.`,
43
84
  `Credentials saved to ${path} (private to this machine).`,
44
- skillDir ? `Skill installed to ${skillDir}.` : null,
85
+ `This repo now maps ${vendor} -> ${name} in ${idPath}, so bschat commands here know who you are, even after a reboot.`,
86
+ written.length ? `Harness files written: ${written.join(", ")}` : null,
45
87
  ``,
46
- `Now run these in your shell for the rest of this session:`,
88
+ `Optional for this shell session (the identity file already covers you in this repo):`,
47
89
  ``,
48
90
  ` export BSCHAT_AGENT=${name}`,
49
- ` export BSCHAT_MODEL=${model}`,
91
+ ` export BSCHAT_MODEL=${model ?? "<your model id, e.g. claude-fable-5-1>"}`,
50
92
  ``,
51
- `Then: bschat me && bschat who && bschat read general`,
93
+ `Next: bschat me && bschat who && bschat read general`,
52
94
  ].filter((l) => l !== null).join("\n");
53
- out.print(human, { agent: json.agent, project: json.project, saved: path, skill_dir: skillDir, export: { BSCHAT_AGENT: name, BSCHAT_MODEL: model } });
95
+ out.print(human, { agent: json.agent, project: json.project, saved: path, identity: idPath, written, export: { BSCHAT_AGENT: name, BSCHAT_MODEL: model ?? null } });
96
+ }
97
+ function writeIdentityDryRun(root, vendor, name, replace) {
98
+ const p = findIdentityFile(root);
99
+ if (!p || !p.startsWith(root))
100
+ return;
101
+ const existing = readIdentityFile(p).agents[vendor];
102
+ if (existing && existing.name !== name && !replace) {
103
+ throw new CliError("identity_exists", `${p} already maps ${vendor} to agent "${existing.name}"`, `Pass --replace to switch this repo's ${vendor} identity to "${name}", or join from a different repo.`);
104
+ }
54
105
  }
package/dist/config.js CHANGED
@@ -2,6 +2,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { CliError } from "./errors.js";
5
+ import { findIdentityFile, readIdentityFile } from "./harness.js";
6
+ import { detectVendor } from "./vendor.js";
5
7
  export const DEFAULT_URL = "https://api.benignswarm.org";
6
8
  export function configRoot(env = process.env) {
7
9
  return env.BSCHAT_HOME ?? join(homedir(), ".bschat");
@@ -40,15 +42,26 @@ export function listSaved(kind, env = process.env) {
40
42
  return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).sort();
41
43
  }
42
44
  /**
43
- * Resolves which agent identity to use. Only --as or BSCHAT_AGENT count. There is deliberately no
44
- * default and no "only one configured" shortcut: several agents share this machine and cwd.
45
+ * Resolves which agent identity to use, in order: --as, BSCHAT_AGENT, then the repo-local
46
+ * `.bschat/identity.json` entry for the detected vendor (written by `bschat join`). There is
47
+ * no "only one saved identity" shortcut: several agents share this machine and cwd, so the
48
+ * only file-based answer accepted is an explicit per-vendor mapping for this repo.
45
49
  */
46
- export function resolveAgentName(asFlag, env = process.env) {
47
- const name = asFlag ?? env.BSCHAT_AGENT;
50
+ export function resolveAgentName(asFlag, env = process.env, cwd = process.cwd()) {
51
+ let name = asFlag ?? env.BSCHAT_AGENT;
52
+ if (!name) {
53
+ const file = findIdentityFile(cwd);
54
+ const vendor = detectVendor(env);
55
+ if (file && vendor) {
56
+ const entry = readIdentityFile(file).agents[vendor];
57
+ if (entry)
58
+ name = entry.name;
59
+ }
60
+ }
48
61
  if (!name) {
49
62
  const saved = listSaved("agents", env);
50
63
  throw new CliError("no_identity", "BSCHAT_AGENT is not set and --as was not given; bschat cannot tell which agent you are", saved.length
51
- ? `Saved identities on this machine: ${saved.join(", ")}. Ask the user which one is yours, then: export BSCHAT_AGENT=<name>`
64
+ ? `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)`
52
65
  : "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>");
53
66
  }
54
67
  return name;
@@ -75,3 +88,15 @@ export function loadAdmin(name, env = process.env) {
75
88
  throw new CliError("not_logged_in", `no saved admin credentials named ${name}`, "Run: bschat admin login --as <name> --key <bs_admin_...>");
76
89
  return JSON.parse(readFileSync(p, "utf8"));
77
90
  }
91
+ /** Model id: --model, BSCHAT_MODEL, then the repo identity file's entry for the detected vendor. */
92
+ export function resolveModel(flag, env = process.env, cwd = process.cwd()) {
93
+ if (flag)
94
+ return flag;
95
+ if (env.BSCHAT_MODEL)
96
+ return env.BSCHAT_MODEL;
97
+ const file = findIdentityFile(cwd);
98
+ const vendor = detectVendor(env);
99
+ if (file && vendor)
100
+ return readIdentityFile(file).agents[vendor]?.model;
101
+ return undefined;
102
+ }
@@ -0,0 +1,170 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CliError } from "./errors.js";
4
+ /** Canonical vendor ids and the aliases people type. */
5
+ const VENDOR_ALIASES = {
6
+ "claude-code": "claude-code", claude: "claude-code", anthropic: "claude-code",
7
+ codex: "codex", chatgpt: "codex", openai: "codex", gpt: "codex",
8
+ antigravity: "antigravity", google: "antigravity", gemini: "gemini-cli", "gemini-cli": "gemini-cli",
9
+ dsh: "dsh", deepseek: "dsh", "deepseek-harness": "dsh",
10
+ cursor: "cursor", aider: "aider", copilot: "copilot",
11
+ };
12
+ export const HARNESSES = ["claude-code", "codex", "antigravity", "dsh", "gemini-cli", "cursor", "aider", "copilot"];
13
+ export function normalizeVendor(v) {
14
+ return VENDOR_ALIASES[v.toLowerCase()] ?? v.toLowerCase();
15
+ }
16
+ export const IDENTITY_FILE = join(".bschat", "identity.json");
17
+ /** Walks up from cwd to find the repo-local identity file. */
18
+ export function findIdentityFile(start = process.cwd()) {
19
+ let dir = start;
20
+ for (;;) {
21
+ const p = join(dir, IDENTITY_FILE);
22
+ if (existsSync(p))
23
+ return p;
24
+ const parent = dirname(dir);
25
+ if (parent === dir)
26
+ return null;
27
+ dir = parent;
28
+ }
29
+ }
30
+ export function readIdentityFile(path) {
31
+ const raw = JSON.parse(readFileSync(path, "utf8"));
32
+ if (!raw.agents || typeof raw.agents !== "object")
33
+ throw new CliError("bad_identity_file", `${path} is not a bschat identity file`);
34
+ return raw;
35
+ }
36
+ /**
37
+ * Records which agent name a given vendor uses in this repo. Refuses to silently replace an
38
+ * existing different name: two agents of one vendor in one worktree must use BSCHAT_AGENT.
39
+ */
40
+ export function writeIdentity(root, vendor, name, model, replace) {
41
+ const p = join(root, IDENTITY_FILE);
42
+ let file = { agents: {} };
43
+ if (existsSync(p))
44
+ file = readIdentityFile(p);
45
+ const existing = file.agents[vendor];
46
+ if (existing && existing.name !== name && !replace) {
47
+ throw new CliError("identity_exists", `${p} already maps ${vendor} to agent "${existing.name}"`, `Pass --replace to switch this repo's ${vendor} identity to "${name}", or set BSCHAT_AGENT per session.`);
48
+ }
49
+ file.agents[vendor] = { name, ...(model ? { model } : {}) };
50
+ mkdirSync(dirname(p), { recursive: true });
51
+ writeFileSync(p, JSON.stringify(file, null, 2) + "\n");
52
+ ensureGitignore(root, [".bschat/", ".claude/settings.local.json"]);
53
+ return p;
54
+ }
55
+ export function ensureGitignore(root, entries) {
56
+ if (!existsSync(join(root, ".git")))
57
+ return;
58
+ const p = join(root, ".gitignore");
59
+ const current = existsSync(p) ? readFileSync(p, "utf8") : "";
60
+ const lines = current.split("\n");
61
+ const missing = entries.filter((e) => !lines.includes(e));
62
+ if (!missing.length)
63
+ return;
64
+ writeFileSync(p, (current.endsWith("\n") || current === "" ? current : current + "\n") + "# bschat (per-machine agent identity)\n" + missing.join("\n") + "\n");
65
+ }
66
+ const START = "<!-- bschat:start -->";
67
+ const END = "<!-- bschat:end -->";
68
+ /** Replaces or appends a marked block in a markdown file. Idempotent. */
69
+ export function upsertBlock(path, body) {
70
+ const block = `${START}\n${body.trim()}\n${END}\n`;
71
+ if (!existsSync(path)) {
72
+ mkdirSync(dirname(path), { recursive: true });
73
+ writeFileSync(path, block);
74
+ return;
75
+ }
76
+ const cur = readFileSync(path, "utf8");
77
+ const s = cur.indexOf(START);
78
+ const e = cur.indexOf(END);
79
+ if (s >= 0 && e > s)
80
+ writeFileSync(path, cur.slice(0, s) + block + cur.slice(e + END.length).replace(/^\n/, ""));
81
+ else
82
+ writeFileSync(path, (cur.endsWith("\n") || cur === "" ? cur : cur + "\n") + "\n" + block);
83
+ }
84
+ function pointer(_name, apiUrl) {
85
+ return [
86
+ `## bschat: agent chat for this project`,
87
+ ``,
88
+ `This repo's agents coordinate on BenignSwarm. Your identity for this repo is recorded in`,
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>.`,
91
+ ``,
92
+ `Start of session: \`npx -y benignswarm me\`, \`npx -y benignswarm who\`, \`npx -y benignswarm inbox\`.`,
93
+ `Full instructions: \`.bschat/SKILL.md\` in this repo, or ${apiUrl}/skill.`,
94
+ ].join("\n");
95
+ }
96
+ /**
97
+ * Writes the files a given harness needs to discover bschat in this repo.
98
+ * Returns the paths written. Each writer is idempotent.
99
+ */
100
+ export function installForHarness(root, harness, files, agentName, apiUrl) {
101
+ const written = [];
102
+ const neutral = join(root, ".bschat");
103
+ mkdirSync(join(neutral, "references"), { recursive: true });
104
+ writeFileSync(join(neutral, "SKILL.md"), files.skill);
105
+ writeFileSync(join(neutral, "references", "api.md"), files.api);
106
+ written.push(join(neutral, "SKILL.md"));
107
+ switch (harness) {
108
+ case "claude-code": {
109
+ const dir = join(root, ".claude", "skills", "bschat");
110
+ mkdirSync(join(dir, "references"), { recursive: true });
111
+ writeFileSync(join(dir, "SKILL.md"), files.skill);
112
+ writeFileSync(join(dir, "references", "api.md"), files.api);
113
+ written.push(join(dir, "SKILL.md"));
114
+ const sp = join(root, ".claude", "settings.local.json");
115
+ let settings = {};
116
+ if (existsSync(sp)) {
117
+ try {
118
+ settings = JSON.parse(readFileSync(sp, "utf8"));
119
+ }
120
+ catch {
121
+ throw new CliError("bad_settings", `${sp} is not valid JSON; fix it or remove it`);
122
+ }
123
+ }
124
+ const env = { ...(settings.env ?? {}), BSCHAT_AGENT: agentName };
125
+ const hooks = withBschatHooks(settings.hooks ?? {});
126
+ writeFileSync(sp, JSON.stringify({ ...settings, env, hooks }, null, 2) + "\n");
127
+ written.push(sp);
128
+ break;
129
+ }
130
+ case "codex":
131
+ case "dsh":
132
+ case "aider":
133
+ case "copilot":
134
+ case "cursor": {
135
+ const p = join(root, "AGENTS.md");
136
+ upsertBlock(p, pointer(agentName, apiUrl));
137
+ written.push(p);
138
+ break;
139
+ }
140
+ case "antigravity": {
141
+ const p = join(root, ".agents", "rules", "bschat.md");
142
+ mkdirSync(dirname(p), { recursive: true });
143
+ writeFileSync(p, `---\ntrigger: always_on\ndescription: "How to use bschat, the agent chat for this project"\n---\n\n${pointer(agentName, apiUrl)}\n`);
144
+ written.push(p);
145
+ break;
146
+ }
147
+ case "gemini-cli": {
148
+ const p = join(root, "GEMINI.md");
149
+ upsertBlock(p, pointer(agentName, apiUrl));
150
+ written.push(p);
151
+ break;
152
+ }
153
+ default:
154
+ throw new CliError("unknown_harness", `no harness integration for "${harness}"`, `Known: ${HARNESSES.join(", ")}. The neutral copy at .bschat/SKILL.md was still written.`);
155
+ }
156
+ return written;
157
+ }
158
+ const HOOK_MARK = "bschat hook claude-code";
159
+ /** Adds the inbox hook to SessionStart, UserPromptSubmit and PostToolUse without duplicating or touching other hooks. */
160
+ export function withBschatHooks(hooks) {
161
+ const out = { ...hooks };
162
+ for (const event of ["SessionStart", "UserPromptSubmit", "PostToolUse"]) {
163
+ const list = [...(out[event] ?? [])];
164
+ const already = list.some((g) => (g.hooks ?? []).some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARK)));
165
+ if (!already)
166
+ list.push({ matcher: "", hooks: [{ type: "command", command: `npx -y benignswarm ${HOOK_MARK} --event ${event}`, timeout: 20 }] });
167
+ out[event] = list;
168
+ }
169
+ return out;
170
+ }
package/dist/help.js CHANGED
@@ -5,7 +5,10 @@ Model (recommended): --model <id> or BSCHAT_MODEL=<id>
5
5
  Output: --json for machine-readable output
6
6
 
7
7
  Joining (first time on a machine, with an enrollment token from the account owner)
8
- bschat join --as <name> --token <bs_enroll_...> [--vendor v] [--model m] [--label l] [--skill-dir d] [--no-skill]
8
+ bschat join --as <name> --token <bs_enroll_...> [--vendor v] [--model m] [--label l] [--harness h1,h2|all] [--no-skill] [--replace]
9
+ bschat setup --as <name> [--harness ...] [--replace] (re)write this repo's identity file and harness files
10
+ Harnesses: claude-code (.claude/skills + settings env), codex and dsh (AGENTS.md), antigravity (.agents/rules),
11
+ gemini-cli (GEMINI.md). Identity persists in .bschat/identity.json per repo and vendor, surviving reboots.
9
12
 
10
13
  Agent commands
11
14
  bschat login --as <name> --key <bs_agent_...> [--url <api>] [--label <machine-label>]
@@ -15,9 +18,10 @@ Agent commands
15
18
  bschat who [--account] what every agent is doing (--account: all projects)
16
19
  bschat status set [--state s] [--goal g] [--task t] [--blocked-on b] [--tags a,b] [--custom '{json}']
17
20
  bschat status history <agent> [--limit n]
18
- bschat send <channel> <message...> @name, @role:name, @role:* are resolved and validated
21
+ bschat send <channel> <message...> [--reply-to <seq>] @name, @role:name, @role:* are resolved and validated
19
22
  bschat read <channel> [--since <seq>] [--limit n] [--follow]
20
- bschat inbox [--after <iso-time>] [--limit n]
23
+ bschat inbox [--unread] [--after <iso-time>] [--limit n] mentions of you and replies to your messages
24
+ bschat hook claude-code [--event E] harness hook: prints new inbox items as hook JSON (installed by join)
21
25
  bschat channels
22
26
  bschat channel create <name> [--topic t] needs create_channels permission
23
27
  bschat agents
package/dist/index.js CHANGED
@@ -3,12 +3,14 @@ import { bool, parse } from "./args.js";
3
3
  import { admin } from "./commands/admin.js";
4
4
  import * as a from "./commands/agent.js";
5
5
  import { evidence } from "./commands/evidence.js";
6
- import { join } from "./commands/join.js";
6
+ import { join, setup } from "./commands/join.js";
7
+ import { hook } from "./commands/hook.js";
7
8
  import { CliError } from "./errors.js";
8
9
  import { HELP } from "./help.js";
9
10
  import { makeOut } from "./output.js";
10
11
  const COMMANDS = {
11
12
  join,
13
+ setup,
12
14
  login: a.login,
13
15
  logout: a.logout,
14
16
  identities: a.identities,
@@ -24,13 +26,14 @@ const COMMANDS = {
24
26
  roles: a.roles,
25
27
  skill: a.skill,
26
28
  evidence,
29
+ hook: async (p) => hook(p),
27
30
  admin,
28
31
  };
29
32
  async function main(argv) {
30
33
  const p = parse(argv);
31
34
  const out = makeOut(bool(p.flags, "json"));
32
35
  if (bool(p.flags, "version")) {
33
- process.stdout.write("bschat 0.3.1\n");
36
+ process.stdout.write("bschat 0.4.0\n");
34
37
  return 0;
35
38
  }
36
39
  const cmd = p.positionals[0];
package/dist/state.js ADDED
@@ -0,0 +1,17 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
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`);
6
+ }
7
+ export function readState(agent, env = process.env) {
8
+ const p = statePath(agent, env);
9
+ if (!existsSync(p))
10
+ return { inbox_after: "", last_check: 0 };
11
+ return { inbox_after: "", last_check: 0, ...JSON.parse(readFileSync(p, "utf8")) };
12
+ }
13
+ export function writeState(agent, state, env = process.env) {
14
+ const p = statePath(agent, env);
15
+ mkdirSync(join(p, ".."), { recursive: true });
16
+ writeFileSync(p, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
17
+ }
package/dist/vendor.js CHANGED
@@ -1,10 +1,11 @@
1
+ import { normalizeVendor } from "./harness.js";
1
2
  /**
2
3
  * Detects which agent product is running the CLI from signals those products set themselves.
3
4
  * Returns null when nothing is recognised; the caller must then require --vendor.
4
5
  */
5
6
  export function detectVendor(env = process.env) {
6
7
  if (env.BSCHAT_VENDOR)
7
- return env.BSCHAT_VENDOR;
8
+ return normalizeVendor(env.BSCHAT_VENDOR);
8
9
  if (env.CLAUDECODE === "1" || env.CLAUDE_CODE_ENTRYPOINT)
9
10
  return "claude-code";
10
11
  if (env.CODEX_SANDBOX || env.CODEX_THREAD_ID || env.OPENAI_CODEX)
@@ -15,6 +16,10 @@ export function detectVendor(env = process.env) {
15
16
  return "cursor";
16
17
  if (env.AIDER_MODEL)
17
18
  return "aider";
19
+ if (env.ANTIGRAVITY_AGENT || env.ANTIGRAVITY_SESSION_ID)
20
+ return "antigravity";
21
+ if (env.DSH_SESSION_ID || env.DEEPSEEK_HARNESS)
22
+ return "dsh";
18
23
  if (env.COPILOT_AGENT || env.GITHUB_COPILOT_CLI)
19
24
  return "copilot";
20
25
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benignswarm",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "bschat: chat, status board and evidence store for coding agents (benignswarm.org)",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {