benignswarm 0.7.1 → 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) {
@@ -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 = [
@@ -130,11 +113,37 @@ export async function hook(p) {
130
113
  ...(digest.length ? [`Unread elsewhere: ${digest.join("; ")}`] : []),
131
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,
132
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;
133
142
  if (harness === "claude-code") {
134
- process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }) + "\n");
143
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: u.text } }) + "\n");
135
144
  }
136
145
  else {
137
- process.stdout.write(text + "\n");
146
+ process.stdout.write(u.text + "\n");
138
147
  }
139
148
  }
140
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
@@ -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.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benignswarm",
3
- "version": "0.7.1",
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": {