benignswarm 0.7.0 → 0.7.2

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) {
@@ -151,6 +160,8 @@ export async function read(p, out) {
151
160
  qs.set("limit", str(p.flags, "limit") ?? "100");
152
161
  const r = await client.call("GET", `/projects/${project}/channels/${channel}/messages?${qs}`);
153
162
  const text = r.messages.length ? r.messages.map(fmtMessage).join("\n") : "(no messages)";
163
+ const st = readState(creds.agent);
164
+ writeState(creds.agent, { ...st, channel_read: { ...(st.channel_read ?? {}), [`${project}/${channel}`]: r.cursor } });
154
165
  out.print(`${text}\ncursor: ${r.cursor}`, r);
155
166
  }
156
167
  function follow(client, path, out) {
@@ -270,3 +281,26 @@ export async function projects(p, out) {
270
281
  const list = r.scope === "account" ? r.projects : [r.project];
271
282
  out.print(list.map((x) => x.name).join("\n") || "(none)", { projects: list });
272
283
  }
284
+ /** Follow a channel: the hook then injects its new messages, not just a digest line. */
285
+ export async function followCmd(p, out) {
286
+ const channel = p.positionals[1];
287
+ const { creds, client } = agentClient(p);
288
+ const unfollow = p.positionals[0] === "unfollow";
289
+ const st = readState(creds.agent);
290
+ const follows = new Set(st.follows ?? []);
291
+ if (p.positionals[0] === "following" || !channel) {
292
+ out.print(follows.size ? [...follows].join("\n") : "(following no channels; the hook shows a digest line only)", { follows: [...follows] });
293
+ return;
294
+ }
295
+ const project = projectOf(p, creds);
296
+ const key = `${project}/${channel}`;
297
+ if (unfollow) {
298
+ follows.delete(key);
299
+ }
300
+ else {
301
+ await client.call("GET", `/projects/${project}/channels/${channel}/messages?limit=1`); // validates access
302
+ follows.add(key);
303
+ }
304
+ writeState(creds.agent, { ...st, follows: [...follows] });
305
+ out.print(`${unfollow ? "unfollowed" : "following"} #${channel} in ${project}`, { follows: [...follows] });
306
+ }
@@ -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,28 +49,101 @@ 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;
58
+ const channelRead = { ...(state.channel_read ?? {}) };
59
+ const follows = new Set(state.follows ?? []);
60
+ const digest = [];
61
+ const followed = [];
62
+ try {
63
+ const projects = creds.project ? [creds.project] : ((await client.call("GET", "/me")).projects ?? []).map((x) => x.name);
64
+ for (const project of projects) {
65
+ const { cursors } = await client.call("GET", `/projects/${project}/channels/cursors`);
66
+ for (const ch of cursors) {
67
+ const key = `${project}/${ch.name}`;
68
+ const last = channelRead[key];
69
+ if (last === undefined) {
70
+ channelRead[key] = ch.cursor;
71
+ continue;
72
+ }
73
+ const unread = ch.cursor - last;
74
+ if (unread <= 0)
75
+ continue;
76
+ if (follows.has(key)) {
77
+ const r = await client.call("GET", `/projects/${project}/channels/${ch.name}/messages?since=${last}&limit=5`);
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)
81
+ followed.push(`${creds.project ? "" : `[${project}] `}${fmtMessage(m)}`);
82
+ const shown = r.messages.length ? r.messages[r.messages.length - 1].seq : last;
83
+ if (ch.cursor > shown)
84
+ followed.push(` (+${ch.cursor - shown} more in #${ch.name}: bschat read ${ch.name} --since ${shown})`);
85
+ channelRead[key] = shown;
86
+ }
87
+ else {
88
+ digest.push(`#${ch.name}${creds.project ? "" : ` [${project}]`}: ${unread} unread (bschat read ${ch.name} --since ${last})`);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ catch (e) {
94
+ process.stderr.write(`bschat: channels: ${e.message}\n`);
95
+ }
96
+ next.channel_read = channelRead;
74
97
  writeState(name, next);
75
- if (!messages.length && !items.length)
76
- return;
98
+ if (!messages.length && !items.length && !digest.length && !followed.length)
99
+ return null;
77
100
  const lines = messages.map((m) => `${creds.project ? "" : `[${m.project}] `}${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`);
78
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})`);
102
+ const head = [
103
+ messages.length ? `${messages.length} message${messages.length === 1 ? "" : "s"} for you` : null,
104
+ items.length ? `${items.length} list item${items.length === 1 ? "" : "s"} assigned to you` : null,
105
+ followed.length ? `new messages in followed channels` : null,
106
+ digest.length ? `unread channels` : null,
107
+ ].filter(Boolean).join(", ");
79
108
  const text = [
80
- `[bschat] ${messages.length} new message${messages.length === 1 ? "" : "s"}${items.length ? ` and ${items.length} list item${items.length === 1 ? "" : "s"} assigned to you` : ""} (${name}) on BenignSwarm:`,
109
+ `[bschat] ${head} (${name}) on BenignSwarm:`,
81
110
  ...lines,
82
111
  ...(items.length ? [`Assigned list items (bschat item show <id>; bschat item set <id> --status ...):`, ...itemLines] : []),
83
- `Reply with: bschat send ${creds.project ? "" : "--project <project> "}<channel> --reply-to <seq> "..." (or just continue if no answer is needed)`,
84
- ].join("\n");
112
+ ...(followed.length ? [`Followed channels:`, ...followed] : []),
113
+ ...(digest.length ? [`Unread elsewhere: ${digest.join("; ")}`] : []),
114
+ 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,
115
+ ].filter((l) => l !== null).join("\n");
116
+ return { text, counts: { messages: messages.length, items: items.length, followed: followed.length, digest: digest.length } };
117
+ }
118
+ /**
119
+ * Harness hook entry point. Prints new items in the format the harness expects for injecting
120
+ * context, and nothing when there is nothing new. Always exits 0: a hook that fails must never
121
+ * break the agent's session. Problems go to stderr.
122
+ */
123
+ export async function hook(p) {
124
+ const harness = p.positionals[1] ?? "plain";
125
+ const event = str(p.flags, "event") ?? (harness === "claude-code" ? "UserPromptSubmit" : "");
126
+ const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT ?? identityFromRepo();
127
+ if (!name)
128
+ return; // not configured here; silent by design
129
+ // Model observation: hook input (SessionStart `model`, PostModelSwitch `to_model`) or the transcript tail.
130
+ const input = readStdinJson();
131
+ if (input) {
132
+ const state = readState(name);
133
+ 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);
134
+ if (observed && observed !== state.model)
135
+ writeState(name, { ...state, model: observed, model_observed_at: Date.now() });
136
+ }
137
+ if (event === "PostModelSwitch")
138
+ return;
139
+ const u = await gatherUpdates(name, { force: !!p.flags.force, model: str(p.flags, "model") });
140
+ if (!u)
141
+ return;
85
142
  if (harness === "claude-code") {
86
- process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }) + "\n");
143
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: u.text } }) + "\n");
87
144
  }
88
145
  else {
89
- process.stdout.write(text + "\n");
146
+ process.stdout.write(u.text + "\n");
90
147
  }
91
148
  }
92
149
  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") });
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\] /, "");
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
@@ -23,7 +23,12 @@ Agent commands
23
23
  bschat send <channel> <message...> [--reply-to <seq>] @name, @role:name, @role:* are resolved and validated
24
24
  bschat read <channel> [--since <seq>] [--limit n] [--follow]
25
25
  bschat inbox [--unread] [--after <iso-time>] [--limit n] mentions of you and replies to your messages
26
+ bschat follow <channel> | unfollow <channel> | following followed channels' new messages are injected by the hook;
27
+ other channels appear as a one-line unread digest
26
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
27
32
  bschat activity [--account] [--after <iso>] [--limit n] merged timeline: messages, status, evidence
28
33
  bschat projects projects you can access
29
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,10 @@ const COMMANDS = {
37
38
  lists,
38
39
  list,
39
40
  item,
41
+ watch,
42
+ follow: a.followCmd,
43
+ unfollow: a.followCmd,
44
+ following: a.followCmd,
40
45
  admin,
41
46
  };
42
47
  async function main(argv) {
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.7.0";
1
+ export const CLI_VERSION = "0.7.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benignswarm",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "bschat: chat, status board and evidence store for coding agents (benignswarm.org)",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {