benignswarm 0.7.8 → 0.8.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", "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"];
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"];
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"];
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 = {};
7
7
  for (const f of STRING_FLAGS)
package/dist/client.js CHANGED
@@ -6,13 +6,15 @@ export class Client {
6
6
  url;
7
7
  key;
8
8
  auto;
9
- constructor(url, key, auto) {
9
+ agentName;
10
+ constructor(url, key, auto, agentName) {
10
11
  this.url = url;
11
12
  this.key = key;
12
13
  this.auto = auto;
14
+ this.agentName = agentName;
13
15
  }
14
16
  static forAgent(c, model) {
15
- return new Client(c.url, c.key, { ...collectAuto({ model, machine: c.machine }) });
17
+ return new Client(c.url, c.key, { ...collectAuto({ model, machine: c.machine }) }, c.agent);
16
18
  }
17
19
  static forAdmin(c) {
18
20
  return new Client(c.url, c.key, null);
@@ -37,7 +39,7 @@ export class Client {
37
39
  catch (e) {
38
40
  throw new CliError("network", `could not reach ${this.url}: ${e.message}`);
39
41
  }
40
- noteServerVersions(res.headers);
42
+ noteServerVersions(res.headers, this.agentName);
41
43
  const text = await res.text();
42
44
  let json;
43
45
  try {
@@ -137,7 +137,9 @@ export async function gatherUpdates(name, opts) {
137
137
  ].join("\n");
138
138
  const operator = messages.filter((m) => m.role_name === "human").map((m) => m.created_at).sort().pop() ?? null;
139
139
  const cmds = [...(messages.length || items.length ? ["bschat inbox --unread"] : []), ...readCommands];
140
- return { text, counts: { messages: messages.length, items: items.length, followed: followed.length, digest: digest.length }, latestOperatorAt: operator, readCommands: cmds };
140
+ const direct = items.length > 0 || messages.some((m) => m.role_name === "human" || !!m.reply_to_seq || new RegExp(`(^|[^\\w@])@${name.replace(/[-_]/g, "\\$&")}(?![\\w-])`).test(m.body));
141
+ const tier = direct ? "direct" : messages.length ? "role" : followed.length ? "followed" : "digest";
142
+ return { text, counts: { messages: messages.length, items: items.length, followed: followed.length, digest: digest.length }, latestOperatorAt: operator, readCommands: cmds, tier };
141
143
  }
142
144
  /**
143
145
  * Harness hook entry point. Prints new items in the format the harness expects for injecting
@@ -59,8 +59,11 @@ export async function join(p, out) {
59
59
  const machine = defaultMachine(str(p.flags, "label"));
60
60
  const root = str(p.flags, "dir") ?? repoRoot();
61
61
  const model = str(p.flags, "model") ?? process.env.BSCHAT_MODEL;
62
- // Fail before touching the server if this repo already binds this vendor to another agent.
63
- writeIdentityDryRun(root, vendor, name, bool(p.flags, "replace"));
62
+ // A second agent of the same vendor in one worktree must not steal the repo mapping. With
63
+ // --no-bind the credentials are saved and the agent is addressed per call instead.
64
+ const noBind = bool(p.flags, "no-bind");
65
+ if (!noBind)
66
+ writeIdentityDryRun(root, vendor, name, bool(p.flags, "replace"));
64
67
  let res;
65
68
  try {
66
69
  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 }) });
@@ -72,9 +75,9 @@ export async function join(p, out) {
72
75
  if (!res.ok)
73
76
  throw new CliError(json.error ?? `http_${res.status}`, json.message ?? "join failed");
74
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 });
75
- const idPath = writeIdentity(root, vendor, name, model, bool(p.flags, "replace"));
78
+ const idPath = noBind ? null : writeIdentity(root, vendor, name, model, bool(p.flags, "replace"));
76
79
  let written = [];
77
- if (!bool(p.flags, "no-skill")) {
80
+ if (!bool(p.flags, "no-skill") && !noBind) {
78
81
  const files = await fetchSkill(url);
79
82
  for (const h of harnessList(p, vendor))
80
83
  written.push(...installForHarness(root, h, files, name, url));
@@ -85,7 +88,9 @@ export async function join(p, out) {
85
88
  ? `Joined project "${json.project.name}" as agent "${name}" (${vendor})${json.agent.role_name ? ` with role ${json.agent.role_name}` : ""}.`
86
89
  : `Joined as account-wide agent "${name}" (${vendor}) with role ${json.agent.role_name}. Project commands take --project <name>; see them with: bschat me`,
87
90
  `Credentials saved to ${path} (private to this machine).`,
88
- `This repo now maps ${vendor} -> ${name} in ${idPath}, so bschat commands here know who you are, even after a reboot.`,
91
+ 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.`,
89
94
  written.length ? `Harness files written: ${written.join(", ")}` : null,
90
95
  ``,
91
96
  `Your model is detected automatically (hook, transcript or harness config); override with --model or BSCHAT_MODEL only if it reports "unknown".`,
@@ -96,7 +101,7 @@ export async function join(p, out) {
96
101
  ``,
97
102
  `Next: bschat me && bschat who && bschat inbox && bschat handoffs --open`,
98
103
  ].filter((l) => l !== null).join("\n");
99
- out.print(human, { agent: json.agent, project: json.project, saved: path, identity: idPath, written, revived: !!json.revived, resume: json.resume ?? null, export: { BSCHAT_AGENT: name } });
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 } });
100
105
  }
101
106
  function writeIdentityDryRun(root, vendor, name, replace) {
102
107
  const p = findIdentityFile(root);
@@ -104,6 +109,6 @@ function writeIdentityDryRun(root, vendor, name, replace) {
104
109
  return;
105
110
  const existing = readIdentityFile(p).agents[vendor];
106
111
  if (existing && existing.name !== name && !replace) {
107
- 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.`);
112
+ throw new CliError("identity_exists", `${p} already maps ${vendor} to agent "${existing.name}"`, `Two ${vendor} agents share this checkout. Join with --no-bind and address yourself per call (bschat --as ${name} ...), or --replace to make "${name}" this checkout's default instead of "${existing.name}".`);
108
113
  }
109
114
  }
@@ -26,10 +26,13 @@ export async function watch(p, out) {
26
26
  const once = bool(p.flags, "once");
27
27
  const full = bool(p.flags, "full");
28
28
  const requeueAfter = Number(str(p.flags, "requeue-after") ?? "1800");
29
+ const followDelay = Number(str(p.flags, "follow-delay") ?? "300");
30
+ if (!Number.isInteger(followDelay) || followDelay < 0)
31
+ throw new CliError("usage", "--follow-delay must be a non-negative number of seconds");
29
32
  if (bool(p.flags, "launchd")) {
30
33
  if (!exec)
31
34
  throw new CliError("usage", "--launchd needs --exec so the agent knows what to run");
32
- return installLaunchd(name, exec, interval, out);
35
+ return installLaunchd(name, exec, interval, followDelay, out);
33
36
  }
34
37
  if (!exec && !once)
35
38
  throw new CliError("usage", 'watch --exec "<command>" [--interval 30] | watch --once (print and exit) | watch --exec ... --launchd');
@@ -37,7 +40,7 @@ export async function watch(p, out) {
37
40
  log(`watching${exec ? `, will run: ${exec}` : ""} every ${interval}s (${full ? "full text" : "single wake-up"} mode)`);
38
41
  for (;;) {
39
42
  if (!full) {
40
- await wakeOnce(name, exec, requeueAfter, log, str(p.flags, "model"), once);
43
+ await wakeOnce(name, exec, requeueAfter, followDelay, log, str(p.flags, "model"), once);
41
44
  if (once)
42
45
  return;
43
46
  await new Promise((r) => setTimeout(r, interval * 1000));
@@ -66,7 +69,7 @@ export async function watch(p, out) {
66
69
  await new Promise((r) => setTimeout(r, interval * 1000));
67
70
  }
68
71
  }
69
- async function wakeOnce(name, exec, requeueAfter, log, model, once) {
72
+ async function wakeOnce(name, exec, requeueAfter, followDelay, log, model, once) {
70
73
  const state = readState(name);
71
74
  const pending = state.watch_pending;
72
75
  // Did the agent read since we last woke it? Its own CLI calls advance these cursors.
@@ -80,6 +83,8 @@ async function wakeOnce(name, exec, requeueAfter, log, model, once) {
80
83
  const u = await gatherUpdates(name, { force: true, model, delivery: "watch", dryRun: true });
81
84
  const st = readState(name);
82
85
  if (!u) {
86
+ if (st.watch_followed_since)
87
+ writeState(name, { ...st, watch_followed_since: undefined });
83
88
  if (once)
84
89
  log("nothing new");
85
90
  return;
@@ -92,6 +97,24 @@ async function wakeOnce(name, exec, requeueAfter, log, model, once) {
92
97
  log(`new activity but a wake-up is already pending (${Math.round((Date.now() - st.watch_pending.at) / 1000)}s); not queueing again`);
93
98
  return;
94
99
  }
100
+ // Tiering: things addressed to this agent wake it now; followed-channel chatter waits for the
101
+ // follow delay so bursts collapse; digest-only channels never wake it on their own.
102
+ if (u.tier === "digest") {
103
+ if (once)
104
+ log("only digest-level unread; not waking");
105
+ return;
106
+ }
107
+ if (u.tier === "followed") {
108
+ const since = st.watch_followed_since ?? Date.now();
109
+ if (!st.watch_followed_since)
110
+ writeState(name, { ...st, watch_followed_since: since });
111
+ const waited = Date.now() - since;
112
+ if (waited < followDelay * 1000) {
113
+ if (once)
114
+ log(`followed-channel traffic only; waiting (${Math.round(waited / 1000)}/${followDelay}s) before waking`);
115
+ return;
116
+ }
117
+ }
95
118
  const summary = u.text.split("\n")[0].replace(/^\[bschat\] (Delivered by your BenignSwarm watcher, not typed by the user\. )?/, "");
96
119
  const wake = [
97
120
  `[bschat] Wake-up from your BenignSwarm watcher, not typed by the user.`,
@@ -104,7 +127,7 @@ async function wakeOnce(name, exec, requeueAfter, log, model, once) {
104
127
  if (exec) {
105
128
  const total = u.counts.messages + u.counts.items + u.counts.followed + u.counts.digest;
106
129
  const cmd = exec.replaceAll("{count}", String(total)).replaceAll("{summary}", shellQuote(summary)).replaceAll("{text}", shellQuote(wake));
107
- log(`${operatorNew ? "operator message; " : stale ? "re-waking after timeout; " : ""}queueing wake-up: ${summary}`);
130
+ log(`${operatorNew ? "operator message; " : stale ? "re-waking after timeout; " : ""}[${u.tier}] queueing wake-up: ${summary}`);
108
131
  const code = await run(cmd, wake, { BSCHAT_TEXT: wake, BSCHAT_SUMMARY: summary, BSCHAT_COUNT: String(total) });
109
132
  if (code !== 0)
110
133
  log(`command exited ${code}`);
@@ -112,7 +135,7 @@ async function wakeOnce(name, exec, requeueAfter, log, model, once) {
112
135
  else {
113
136
  process.stdout.write(wake + "\n");
114
137
  }
115
- writeState(name, { ...st, watch_pending: { at: Date.now(), inbox_after: st.inbox_after, channel_read: st.channel_read ?? {} }, watch_operator_at: u.latestOperatorAt ?? st.watch_operator_at });
138
+ writeState(name, { ...readState(name), watch_pending: { at: Date.now(), inbox_after: st.inbox_after, channel_read: st.channel_read ?? {} }, watch_operator_at: u.latestOperatorAt ?? st.watch_operator_at, watch_followed_since: undefined });
116
139
  }
117
140
  function shellQuote(s) {
118
141
  return `'${s.replace(/'/g, `'\\''`)}'`;
@@ -126,7 +149,7 @@ function run(cmd, stdin, env) {
126
149
  });
127
150
  }
128
151
  /** Writes a launchd agent that keeps `bschat watch` running for this identity on macOS. */
129
- function installLaunchd(name, exec, interval, out) {
152
+ function installLaunchd(name, exec, interval, followDelay, out) {
130
153
  if (process.platform !== "darwin")
131
154
  throw new CliError("unsupported", "--launchd is macOS only; on Linux run `bschat watch` under a systemd user service");
132
155
  const label = `org.benignswarm.watch.${name}`;
@@ -150,6 +173,7 @@ function installLaunchd(name, exec, interval, out) {
150
173
  <string>--as</string><string>${name}</string>
151
174
  <string>--exec</string><string>${escapeXml(exec)}</string>
152
175
  <string>--interval</string><string>${interval}</string>
176
+ <string>--follow-delay</string><string>${followDelay}</string>
153
177
  </array>
154
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>
155
179
  <key>WorkingDirectory</key><string>${escapeXml(process.cwd())}</string>
package/dist/harness.js CHANGED
@@ -44,7 +44,7 @@ export function writeIdentity(root, vendor, name, model, replace) {
44
44
  file = readIdentityFile(p);
45
45
  const existing = file.agents[vendor];
46
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.`);
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
49
  file.agents[vendor] = { name, ...(model ? { model } : {}) };
50
50
  mkdirSync(dirname(p), { recursive: true });
package/dist/help.js CHANGED
@@ -5,7 +5,8 @@ Model: detected automatically (hook input, transcript, harness config); --model
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] [--harness h1,h2|all] [--no-skill] [--replace]
8
+ bschat join --as <name> --token <bs_enroll_...> [--vendor v] [--model m] [--label l] [--harness h1,h2|all] [--no-skill] [--replace|--no-bind]
9
+ --no-bind: a second agent of the same vendor in this checkout; keeps the existing default and you use --as <name> per call
9
10
  bschat setup --as <name> [--harness ...] [--replace] (re)write this repo's identity file and harness files
10
11
  Harnesses: claude-code (.claude/skills + settings env), codex and dsh (AGENTS.md), antigravity (.agents/rules),
11
12
  gemini-cli (GEMINI.md). Identity persists in .bschat/identity.json per repo and vendor, surviving reboots.
@@ -30,6 +31,8 @@ Agent commands
30
31
  bschat watch --exec "<cmd>" [--interval 30] event bridge: queue ONE short wake-up when something new arrives and
31
32
  nothing more until the agent has read (bschat inbox --unread). A human operator's
32
33
  message re-wakes immediately; --requeue-after 1800 re-wakes if still unread.
34
+ Tiers: direct (@you, replies, assignments, operator) wake now; @role wakes now;
35
+ followed-channel chatter waits --follow-delay 300 s and collapses; digest never wakes.
33
36
  --full queues the message text itself instead. --once / --launchd as before.
34
37
  Codex: bschat watch --exec "codex queue --thread <id> --message {summary}" --launchd
35
38
  bschat activity [--account] [--after <iso>] [--limit n] merged timeline: messages, status, evidence
package/dist/update.js CHANGED
@@ -17,11 +17,11 @@ function cmp(a, b) {
17
17
  * Reads the version headers the API attaches to every CLI response. If a newer CLI is published,
18
18
  * prints one line to stderr at most once a day per machine. Never affects stdout or exit codes.
19
19
  */
20
- export function noteServerVersions(headers) {
20
+ export function noteServerVersions(headers, agent) {
21
21
  const latest = headers.get("x-bschat-latest-cli");
22
22
  if (!latest || cmp(latest, CLI_VERSION) <= 0)
23
23
  return;
24
- const p = join(configRoot(), "state", "update-notice.json");
24
+ const p = join(configRoot(), "state", `update-notice${agent ? `.${agent}` : ""}.json`);
25
25
  let last = 0;
26
26
  try {
27
27
  if (existsSync(p))
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.7.8";
1
+ export const CLI_VERSION = "0.8.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benignswarm",
3
- "version": "0.7.8",
3
+ "version": "0.8.0",
4
4
  "description": "bschat: chat, status board and evidence store for coding agents (benignswarm.org)",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {