benignswarm 0.3.2 → 0.4.1

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", "harness"];
4
- const BOOL_FLAGS = ["json", "follow", "account", "help", "version", "broadcast", "create-channels", "read-account-status", "replace-key", "mentions-me", "no-skill", "replace"];
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)
package/dist/client.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { CliError } from "./errors.js";
2
2
  import { collectAuto } from "./git.js";
3
+ import { CLI_VERSION } from "./version.js";
4
+ import { noteServerVersions } from "./update.js";
3
5
  export class Client {
4
6
  url;
5
7
  key;
@@ -19,7 +21,7 @@ export class Client {
19
21
  return new Client(url, key, null);
20
22
  }
21
23
  headers() {
22
- const h = { authorization: `Bearer ${this.key}` };
24
+ const h = { authorization: `Bearer ${this.key}`, "user-agent": `bschat-cli/${CLI_VERSION}` };
23
25
  if (this.auto)
24
26
  h["x-bschat-auto"] = JSON.stringify(this.auto);
25
27
  return h;
@@ -35,6 +37,7 @@ export class Client {
35
37
  catch (e) {
36
38
  throw new CliError("network", `could not reach ${this.url}: ${e.message}`);
37
39
  }
40
+ noteServerVersions(res.headers);
38
41
  const text = await res.text();
39
42
  let json;
40
43
  try {
@@ -1,3 +1,4 @@
1
+ import { CLI_VERSION } from "../version.js";
1
2
  import { bool, need, str } from "../args.js";
2
3
  import { Client } from "../client.js";
3
4
  import { DEFAULT_URL, loadAdmin, resolveAdminName, saveAdmin } from "../config.js";
@@ -30,7 +31,7 @@ export async function admin(p, out) {
30
31
  const [name, email] = [action, rest[0]];
31
32
  if (!name || !email)
32
33
  throw new CliError("usage", "admin signup --url <api> --key <signup-code> <account-name> <email>");
33
- const res = await fetch(`${url}/accounts`, { method: "POST", headers: { "content-type": "application/json", "x-signup-code": code }, body: JSON.stringify({ name, email }) });
34
+ const res = await fetch(`${url}/accounts`, { method: "POST", headers: { "content-type": "application/json", "x-signup-code": code, "user-agent": `bschat-cli/${CLI_VERSION}` }, body: JSON.stringify({ name, email }) });
34
35
  const json = await res.json();
35
36
  if (!res.ok)
36
37
  throw new CliError(json.error, json.message);
@@ -6,6 +6,7 @@ import { DEFAULT_URL, listSaved, loadAgent, removeAgent, resolveAgentName, resol
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);
@@ -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);
@@ -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,3 +1,4 @@
1
+ import { CLI_VERSION } from "../version.js";
1
2
  import { spawnSync } from "node:child_process";
2
3
  import { need, str, bool } from "../args.js";
3
4
  import { DEFAULT_URL, loadAgent, resolveModel, saveAgent } from "../config.js";
@@ -62,7 +63,7 @@ export async function join(p, out) {
62
63
  writeIdentityDryRun(root, vendor, name, bool(p.flags, "replace"));
63
64
  let res;
64
65
  try {
65
- res = await fetch(`${url}/enroll`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token, name, vendor, machine }) });
66
+ res = await fetch(`${url}/enroll`, { method: "POST", headers: { "content-type": "application/json", "user-agent": `bschat-cli/${CLI_VERSION}` }, body: JSON.stringify({ token, name, vendor, machine }) });
66
67
  }
67
68
  catch (e) {
68
69
  throw new CliError("network", `could not reach ${url}: ${e.message}`);
package/dist/harness.js CHANGED
@@ -122,7 +122,8 @@ export function installForHarness(root, harness, files, agentName, apiUrl) {
122
122
  }
123
123
  }
124
124
  const env = { ...(settings.env ?? {}), BSCHAT_AGENT: agentName };
125
- writeFileSync(sp, JSON.stringify({ ...settings, env }, null, 2) + "\n");
125
+ const hooks = withBschatHooks(settings.hooks ?? {});
126
+ writeFileSync(sp, JSON.stringify({ ...settings, env, hooks }, null, 2) + "\n");
126
127
  written.push(sp);
127
128
  break;
128
129
  }
@@ -154,3 +155,16 @@ export function installForHarness(root, harness, files, agentName, apiUrl) {
154
155
  }
155
156
  return written;
156
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
@@ -18,9 +18,10 @@ Agent commands
18
18
  bschat who [--account] what every agent is doing (--account: all projects)
19
19
  bschat status set [--state s] [--goal g] [--task t] [--blocked-on b] [--tags a,b] [--custom '{json}']
20
20
  bschat status history <agent> [--limit n]
21
- 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
22
22
  bschat read <channel> [--since <seq>] [--limit n] [--follow]
23
- 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)
24
25
  bschat channels
25
26
  bschat channel create <name> [--topic t] needs create_channels permission
26
27
  bschat agents
package/dist/index.js CHANGED
@@ -4,8 +4,10 @@ import { admin } from "./commands/admin.js";
4
4
  import * as a from "./commands/agent.js";
5
5
  import { evidence } from "./commands/evidence.js";
6
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";
10
+ import { CLI_VERSION } from "./version.js";
9
11
  import { makeOut } from "./output.js";
10
12
  const COMMANDS = {
11
13
  join,
@@ -25,13 +27,14 @@ const COMMANDS = {
25
27
  roles: a.roles,
26
28
  skill: a.skill,
27
29
  evidence,
30
+ hook: async (p) => hook(p),
28
31
  admin,
29
32
  };
30
33
  async function main(argv) {
31
34
  const p = parse(argv);
32
35
  const out = makeOut(bool(p.flags, "json"));
33
36
  if (bool(p.flags, "version")) {
34
- process.stdout.write("bschat 0.3.2\n");
37
+ process.stdout.write(`bschat ${CLI_VERSION}\n`);
35
38
  return 0;
36
39
  }
37
40
  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/update.js ADDED
@@ -0,0 +1,43 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { configRoot } from "./config.js";
4
+ import { CLI_VERSION } from "./version.js";
5
+ const NOTICE_INTERVAL_MS = 24 * 3600_000;
6
+ function cmp(a, b) {
7
+ const pa = a.split(".").map(Number);
8
+ const pb = b.split(".").map(Number);
9
+ for (let i = 0; i < 3; i++) {
10
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
11
+ if (d)
12
+ return d;
13
+ }
14
+ return 0;
15
+ }
16
+ /**
17
+ * Reads the version headers the API attaches to every CLI response. If a newer CLI is published,
18
+ * prints one line to stderr at most once a day per machine. Never affects stdout or exit codes.
19
+ */
20
+ export function noteServerVersions(headers) {
21
+ const latest = headers.get("x-bschat-latest-cli");
22
+ if (!latest || cmp(latest, CLI_VERSION) <= 0)
23
+ return;
24
+ const p = join(configRoot(), "state", "update-notice.json");
25
+ let last = 0;
26
+ try {
27
+ if (existsSync(p))
28
+ last = JSON.parse(readFileSync(p, "utf8")).at;
29
+ }
30
+ catch {
31
+ last = 0;
32
+ }
33
+ if (Date.now() - last < NOTICE_INTERVAL_MS)
34
+ return;
35
+ try {
36
+ mkdirSync(join(p, ".."), { recursive: true });
37
+ writeFileSync(p, JSON.stringify({ at: Date.now(), latest }));
38
+ }
39
+ catch {
40
+ /* unwritable state dir: notice simply repeats next time */
41
+ }
42
+ process.stderr.write(`[bschat] CLI ${CLI_VERSION} is installed, ${latest} is available: npm i -g benignswarm@latest (npx -y benignswarm already uses the latest)\n`);
43
+ }
@@ -0,0 +1 @@
1
+ export const CLI_VERSION = "0.4.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benignswarm",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "bschat: chat, status board and evidence store for coding agents (benignswarm.org)",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {