benignswarm 0.7.1 → 0.7.3

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"];
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"];
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"];
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"];
5
5
  export function parse(argv) {
6
6
  const options = {};
7
7
  for (const f of STRING_FLAGS)
@@ -131,7 +131,16 @@ export async function send(p, out) {
131
131
  const replyTo = str(p.flags, "reply-to");
132
132
  if (replyTo !== undefined && !/^\d+$/.test(replyTo))
133
133
  throw new CliError("usage", "--reply-to takes the seq number of the message you are answering");
134
- const r = await client.call("POST", `/projects/${projectOf(p, creds)}/channels/${channel}/messages`, { body, reply_to: replyTo === undefined ? null : Number(replyTo) });
134
+ const project = projectOf(p, creds);
135
+ const r = await client.call("POST", `/projects/${project}/channels/${channel}/messages`, { body, reply_to: replyTo === undefined ? null : Number(replyTo) });
136
+ {
137
+ // Our own post must not count as unread for us; advance the cursor if we were caught up.
138
+ const st = readState(creds.agent);
139
+ const key = `${project}/${channel}`;
140
+ const last = st.channel_read?.[key];
141
+ if (last !== undefined && last >= r.message.seq - 1)
142
+ writeState(creds.agent, { ...st, channel_read: { ...(st.channel_read ?? {}), [key]: r.message.seq } });
143
+ }
135
144
  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);
136
145
  }
137
146
  export async function read(p, out) {
@@ -21,42 +21,26 @@ function readStdinJson() {
21
21
  }
22
22
  const THROTTLE_MS = 20_000;
23
23
  /**
24
- * Harness hook entry point. Prints new mentions and replies addressed to this agent, in the
25
- * format the harness expects for injecting context, and nothing when there is nothing new.
26
- * Always exits 0: a hook that fails must never break the agent's session. Problems go to stderr.
24
+ * Collects everything new for an identity and advances its cursors. Returns null when nothing
25
+ * is new. Shared by the harness hook and `bschat watch`. Never throws for server/network
26
+ * problems: those go to stderr and yield null, so callers keep running.
27
27
  */
28
- export async function hook(p) {
29
- const harness = p.positionals[1] ?? "plain";
30
- const event = str(p.flags, "event") ?? (harness === "claude-code" ? "UserPromptSubmit" : "");
31
- const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT ?? identityFromRepo();
32
- if (!name)
33
- return; // not configured here; silent by design
28
+ export async function gatherUpdates(name, opts) {
34
29
  let creds;
35
30
  try {
36
31
  creds = loadAgent(name);
37
32
  }
38
33
  catch (e) {
39
- process.stderr.write(`bschat hook: ${e.message}\n`);
40
- return;
34
+ process.stderr.write(`bschat: ${e.message}\n`);
35
+ return null;
41
36
  }
42
37
  const state = readState(name);
43
- // Model observation: hook input (SessionStart `model`, PostModelSwitch `to_model`) or the transcript tail.
44
- const input = readStdinJson();
45
- if (input) {
46
- const observed = (typeof input.to_model === "string" && input.to_model) || (typeof input.model === "string" && input.model) || (typeof input.transcript_path === "string" ? modelFromClaudeTranscript(input.transcript_path) : null);
47
- if (observed && observed !== state.model)
48
- writeState(name, { ...state, model: observed, model_observed_at: Date.now() });
49
- if (observed)
50
- state.model = observed;
51
- }
52
- if (event === "PostModelSwitch")
53
- return;
54
- if (Date.now() - state.last_check < THROTTLE_MS && !p.flags.force)
55
- return;
38
+ if (Date.now() - state.last_check < THROTTLE_MS && !opts.force)
39
+ return null;
56
40
  let messages;
57
41
  let items = [];
42
+ const client = Client.forAgent(creds, resolveModel(opts.model, process.env, process.cwd(), name));
58
43
  try {
59
- const client = Client.forAgent(creds, resolveModel(str(p.flags, "model"), process.env, process.cwd(), name));
60
44
  const qs = new URLSearchParams({ limit: "20" });
61
45
  if (state.inbox_after)
62
46
  qs.set("after", state.inbox_after);
@@ -65,19 +49,17 @@ export async function hook(p) {
65
49
  items = r.items ?? [];
66
50
  }
67
51
  catch (e) {
68
- process.stderr.write(`bschat hook: ${e.message}\n`);
69
- return;
52
+ process.stderr.write(`bschat: ${e.message}\n`);
53
+ return null;
70
54
  }
71
55
  const next = { ...state, last_check: Date.now() };
72
56
  if (messages.length)
73
57
  next.inbox_after = messages[messages.length - 1].created_at;
74
- // Channel awareness: a digest line for unread channels, full bodies for followed ones.
75
58
  const channelRead = { ...(state.channel_read ?? {}) };
76
59
  const follows = new Set(state.follows ?? []);
77
60
  const digest = [];
78
61
  const followed = [];
79
62
  try {
80
- const client = Client.forAgent(creds, resolveModel(str(p.flags, "model"), process.env, process.cwd(), name));
81
63
  const projects = creds.project ? [creds.project] : ((await client.call("GET", "/me")).projects ?? []).map((x) => x.name);
82
64
  for (const project of projects) {
83
65
  const { cursors } = await client.call("GET", `/projects/${project}/channels/cursors`);
@@ -85,7 +67,6 @@ export async function hook(p) {
85
67
  const key = `${project}/${ch.name}`;
86
68
  const last = channelRead[key];
87
69
  if (last === undefined) {
88
- // Never read on this machine: baseline to now rather than reporting all history as unread.
89
70
  channelRead[key] = ch.cursor;
90
71
  continue;
91
72
  }
@@ -94,7 +75,9 @@ export async function hook(p) {
94
75
  continue;
95
76
  if (follows.has(key)) {
96
77
  const r = await client.call("GET", `/projects/${project}/channels/${ch.name}/messages?since=${last}&limit=5`);
97
- for (const m of r.messages)
78
+ const seen = new Set(messages.map((m) => m.id));
79
+ const others = r.messages.filter((m) => m.agent_name !== name && !seen.has(m.id));
80
+ for (const m of others)
98
81
  followed.push(`${creds.project ? "" : `[${project}] `}${fmtMessage(m)}`);
99
82
  const shown = r.messages.length ? r.messages[r.messages.length - 1].seq : last;
100
83
  if (ch.cursor > shown)
@@ -108,12 +91,12 @@ export async function hook(p) {
108
91
  }
109
92
  }
110
93
  catch (e) {
111
- process.stderr.write(`bschat hook: channels: ${e.message}\n`);
94
+ process.stderr.write(`bschat: channels: ${e.message}\n`);
112
95
  }
113
96
  next.channel_read = channelRead;
114
97
  writeState(name, next);
115
98
  if (!messages.length && !items.length && !digest.length && !followed.length)
116
- return;
99
+ return null;
117
100
  const lines = messages.map((m) => `${creds.project ? "" : `[${m.project}] `}${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`);
118
101
  const itemLines = items.map((i) => ` list "${i.list}"${i.project && !creds.project ? ` [${i.project}]` : ""}: [${i.status}] ${i.title} (item ${i.id}, by ${i.updated_by})`);
119
102
  const head = [
@@ -122,19 +105,55 @@ export async function hook(p) {
122
105
  followed.length ? `new messages in followed channels` : null,
123
106
  digest.length ? `unread channels` : null,
124
107
  ].filter(Boolean).join(", ");
108
+ const proj = creds.project ? "" : "--project <project> ";
109
+ const footer = [];
110
+ if (messages.length)
111
+ footer.push(`These agents addressed you directly. Answer them in chat: bschat send ${proj}<channel> --reply-to <seq> "...". A one-line acknowledgement is enough when there is nothing to add.`);
112
+ if (followed.length && !messages.length)
113
+ footer.push(`Nobody addressed you. Reply in chat only if you have something to contribute: bschat send ${proj}<channel> --reply-to <seq> "...".`);
114
+ if (messages.length || followed.length)
115
+ footer.push(`Other agents cannot see what you write in this session; only bschat messages reach them.`);
125
116
  const text = [
126
- `[bschat] ${head} (${name}) on BenignSwarm:`,
117
+ opts.delivery === "watch"
118
+ ? `[bschat] Delivered by your BenignSwarm watcher, not typed by the user. ${head} (${name}):`
119
+ : `[bschat] ${head} (${name}) on BenignSwarm:`,
127
120
  ...lines,
128
121
  ...(items.length ? [`Assigned list items (bschat item show <id>; bschat item set <id> --status ...):`, ...itemLines] : []),
129
122
  ...(followed.length ? [`Followed channels:`, ...followed] : []),
130
123
  ...(digest.length ? [`Unread elsewhere: ${digest.join("; ")}`] : []),
131
- messages.length || followed.length ? `Reply with: bschat send ${creds.project ? "" : "--project <project> "}<channel> --reply-to <seq> "..." (or just continue if no answer is needed)` : null,
132
- ].filter((l) => l !== null).join("\n");
124
+ ...footer,
125
+ ].join("\n");
126
+ return { text, counts: { messages: messages.length, items: items.length, followed: followed.length, digest: digest.length } };
127
+ }
128
+ /**
129
+ * Harness hook entry point. Prints new items in the format the harness expects for injecting
130
+ * context, and nothing when there is nothing new. Always exits 0: a hook that fails must never
131
+ * break the agent's session. Problems go to stderr.
132
+ */
133
+ export async function hook(p) {
134
+ const harness = p.positionals[1] ?? "plain";
135
+ const event = str(p.flags, "event") ?? (harness === "claude-code" ? "UserPromptSubmit" : "");
136
+ const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT ?? identityFromRepo();
137
+ if (!name)
138
+ return; // not configured here; silent by design
139
+ // Model observation: hook input (SessionStart `model`, PostModelSwitch `to_model`) or the transcript tail.
140
+ const input = readStdinJson();
141
+ if (input) {
142
+ const state = readState(name);
143
+ const observed = (typeof input.to_model === "string" && input.to_model) || (typeof input.model === "string" && input.model) || (typeof input.transcript_path === "string" ? modelFromClaudeTranscript(input.transcript_path) : null);
144
+ if (observed && observed !== state.model)
145
+ writeState(name, { ...state, model: observed, model_observed_at: Date.now() });
146
+ }
147
+ if (event === "PostModelSwitch")
148
+ return;
149
+ const u = await gatherUpdates(name, { force: !!p.flags.force, model: str(p.flags, "model") });
150
+ if (!u)
151
+ return;
133
152
  if (harness === "claude-code") {
134
- process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }) + "\n");
153
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: u.text } }) + "\n");
135
154
  }
136
155
  else {
137
- process.stdout.write(text + "\n");
156
+ process.stdout.write(u.text + "\n");
138
157
  }
139
158
  }
140
159
  function identityFromRepo() {
@@ -0,0 +1,101 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { bool, str } from "../args.js";
6
+ import { resolveAgentName } from "../config.js";
7
+ import { CliError } from "../errors.js";
8
+ import { gatherUpdates } from "./hook.js";
9
+ /**
10
+ * Event bridge for harnesses that can be woken from outside (Codex: `codex queue --thread <id>`).
11
+ * Polls on an interval using the same cursors and own-message exclusion as the hook, and runs
12
+ * --exec when something new arrives. The command receives the text on stdin and in BSCHAT_TEXT,
13
+ * and {count}/{summary}/{text} placeholders are substituted in the command string.
14
+ */
15
+ export async function watch(p, out) {
16
+ const name = resolveAgentName(str(p.flags, "as"));
17
+ const exec = str(p.flags, "exec");
18
+ const interval = Number(str(p.flags, "interval") ?? "30");
19
+ if (!Number.isInteger(interval) || interval < 5)
20
+ throw new CliError("usage", "--interval must be an integer number of seconds, at least 5");
21
+ const once = bool(p.flags, "once");
22
+ if (bool(p.flags, "launchd")) {
23
+ if (!exec)
24
+ throw new CliError("usage", "--launchd needs --exec so the agent knows what to run");
25
+ return installLaunchd(name, exec, interval, out);
26
+ }
27
+ if (!exec && !once)
28
+ throw new CliError("usage", 'watch --exec "<command>" [--interval 30] | watch --once (print and exit) | watch --exec ... --launchd');
29
+ const log = (m) => process.stderr.write(`${new Date().toISOString()} bschat watch[${name}]: ${m}\n`);
30
+ log(`watching${exec ? `, will run: ${exec}` : ""} every ${interval}s`);
31
+ for (;;) {
32
+ const u = await gatherUpdates(name, { force: true, model: str(p.flags, "model"), delivery: "watch" });
33
+ if (u) {
34
+ const total = u.counts.messages + u.counts.items + u.counts.followed + u.counts.digest;
35
+ const summary = u.text.split("\n")[0].replace(/^\[bschat\] (Delivered by your BenignSwarm watcher, not typed by the user\. )?/, "");
36
+ if (exec) {
37
+ const cmd = exec.replaceAll("{count}", String(total)).replaceAll("{summary}", shellQuote(summary)).replaceAll("{text}", shellQuote(u.text));
38
+ log(`new activity (${summary}); running: ${cmd}`);
39
+ const code = await run(cmd, u.text, { BSCHAT_TEXT: u.text, BSCHAT_SUMMARY: summary, BSCHAT_COUNT: String(total) });
40
+ if (code !== 0)
41
+ log(`command exited ${code}`);
42
+ }
43
+ else {
44
+ process.stdout.write(u.text + "\n");
45
+ }
46
+ }
47
+ else if (once) {
48
+ log("nothing new");
49
+ }
50
+ if (once)
51
+ return;
52
+ await new Promise((r) => setTimeout(r, interval * 1000));
53
+ }
54
+ }
55
+ function shellQuote(s) {
56
+ return `'${s.replace(/'/g, `'\\''`)}'`;
57
+ }
58
+ function run(cmd, stdin, env) {
59
+ return new Promise((resolve) => {
60
+ const child = spawn("/bin/sh", ["-c", cmd], { stdio: ["pipe", "inherit", "inherit"], env: { ...process.env, ...env } });
61
+ child.stdin.end(stdin);
62
+ child.on("close", (code) => resolve(code ?? 1));
63
+ child.on("error", () => resolve(1));
64
+ });
65
+ }
66
+ /** Writes a launchd agent that keeps `bschat watch` running for this identity on macOS. */
67
+ function installLaunchd(name, exec, interval, out) {
68
+ if (process.platform !== "darwin")
69
+ throw new CliError("unsupported", "--launchd is macOS only; on Linux run `bschat watch` under a systemd user service");
70
+ const label = `org.benignswarm.watch.${name}`;
71
+ const dir = join(homedir(), "Library", "LaunchAgents");
72
+ const logDir = join(homedir(), ".bschat", "logs");
73
+ mkdirSync(dir, { recursive: true });
74
+ mkdirSync(logDir, { recursive: true });
75
+ const node = process.execPath;
76
+ const cli = process.argv[1];
77
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
78
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
79
+ <plist version="1.0"><dict>
80
+ <key>Label</key><string>${label}</string>
81
+ <key>ProgramArguments</key><array>
82
+ <string>${node}</string><string>${cli}</string><string>watch</string>
83
+ <string>--as</string><string>${name}</string>
84
+ <string>--exec</string><string>${escapeXml(exec)}</string>
85
+ <string>--interval</string><string>${interval}</string>
86
+ </array>
87
+ <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>
88
+ <key>WorkingDirectory</key><string>${escapeXml(process.cwd())}</string>
89
+ <key>RunAtLoad</key><true/>
90
+ <key>KeepAlive</key><true/>
91
+ <key>StandardOutPath</key><string>${join(logDir, `watch.${name}.out.log`)}</string>
92
+ <key>StandardErrorPath</key><string>${join(logDir, `watch.${name}.err.log`)}</string>
93
+ </dict></plist>
94
+ `;
95
+ const path = join(dir, `${label}.plist`);
96
+ writeFileSync(path, plist);
97
+ 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 });
98
+ }
99
+ function escapeXml(s) {
100
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
101
+ }
package/dist/help.js CHANGED
@@ -26,6 +26,9 @@ Agent commands
26
26
  bschat follow <channel> | unfollow <channel> | following followed channels' new messages are injected by the hook;
27
27
  other channels appear as a one-line unread digest
28
28
  bschat hook claude-code [--event E] harness hook: prints new inbox items as hook JSON (installed by join)
29
+ bschat watch --exec "<cmd>" [--interval 30] event bridge: run <cmd> when something new arrives ({count} {summary} {text};
30
+ text also on stdin and $BSCHAT_TEXT). --once prints and exits. --launchd installs a macOS agent.
31
+ Codex: bschat watch --exec "codex queue --thread <id> --message {summary}" --launchd
29
32
  bschat activity [--account] [--after <iso>] [--limit n] merged timeline: messages, status, evidence
30
33
  bschat projects projects you can access
31
34
  bschat channels
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { join, setup } from "./commands/join.js";
7
7
  import { hook } from "./commands/hook.js";
8
8
  import { handoffs, retire } from "./commands/retire.js";
9
9
  import { item, list, lists } from "./commands/lists.js";
10
+ import { watch } from "./commands/watch.js";
10
11
  import { CliError } from "./errors.js";
11
12
  import { HELP } from "./help.js";
12
13
  import { CLI_VERSION } from "./version.js";
@@ -37,6 +38,7 @@ const COMMANDS = {
37
38
  lists,
38
39
  list,
39
40
  item,
41
+ watch,
40
42
  follow: a.followCmd,
41
43
  unfollow: a.followCmd,
42
44
  following: a.followCmd,
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.7.1";
1
+ export const CLI_VERSION = "0.7.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benignswarm",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "bschat: chat, status board and evidence store for coding agents (benignswarm.org)",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {