benignswarm 0.7.4 → 0.7.5

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"];
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"];
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"];
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"];
5
5
  export function parse(argv) {
6
6
  const options = {};
7
7
  for (const f of STRING_FLAGS)
@@ -59,6 +59,7 @@ export async function gatherUpdates(name, opts) {
59
59
  const follows = new Set(state.follows ?? []);
60
60
  const digest = [];
61
61
  const followed = [];
62
+ const readCommands = [];
62
63
  try {
63
64
  const projects = creds.project ? [creds.project] : ((await client.call("GET", "/me")).projects ?? []).map((x) => x.name);
64
65
  for (const project of projects) {
@@ -73,6 +74,7 @@ export async function gatherUpdates(name, opts) {
73
74
  const unread = ch.cursor - last;
74
75
  if (unread <= 0)
75
76
  continue;
77
+ readCommands.push(`bschat read ${creds.project ? "" : `--project ${project} `}${ch.name} --since ${last}`);
76
78
  if (follows.has(key)) {
77
79
  const r = await client.call("GET", `/projects/${project}/channels/${ch.name}/messages?since=${last}&limit=5`);
78
80
  const seen = new Set(messages.map((m) => m.id));
@@ -94,7 +96,17 @@ export async function gatherUpdates(name, opts) {
94
96
  process.stderr.write(`bschat: channels: ${e.message}\n`);
95
97
  }
96
98
  next.channel_read = channelRead;
97
- writeState(name, next);
99
+ if (opts.dryRun) {
100
+ // Persist only the baselines of channels never seen before; everything else stays unread.
101
+ const merged = { ...(state.channel_read ?? {}) };
102
+ for (const [k, v] of Object.entries(channelRead))
103
+ if (merged[k] === undefined)
104
+ merged[k] = v;
105
+ writeState(name, { ...state, channel_read: merged });
106
+ }
107
+ else {
108
+ writeState(name, next);
109
+ }
98
110
  if (!messages.length && !items.length && !digest.length && !followed.length)
99
111
  return null;
100
112
  const lines = messages.map((m) => `${creds.project ? "" : `[${m.project}] `}${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`);
@@ -123,7 +135,9 @@ export async function gatherUpdates(name, opts) {
123
135
  ...(digest.length ? [`Unread elsewhere: ${digest.join("; ")}`] : []),
124
136
  ...footer,
125
137
  ].join("\n");
126
- return { text, counts: { messages: messages.length, items: items.length, followed: followed.length, digest: digest.length } };
138
+ const operator = messages.filter((m) => m.role_name === "human").map((m) => m.created_at).sort().pop() ?? null;
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 };
127
141
  }
128
142
  /**
129
143
  * Harness hook entry point. Prints new items in the format the harness expects for injecting
@@ -6,11 +6,16 @@ import { bool, str } from "../args.js";
6
6
  import { resolveAgentName } from "../config.js";
7
7
  import { CliError } from "../errors.js";
8
8
  import { gatherUpdates } from "./hook.js";
9
+ import { readState, writeState } from "../state.js";
9
10
  /**
10
11
  * 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.
12
+ *
13
+ * Default mode queues ONE short wake-up and nothing else until the agent has read (its own
14
+ * `bschat inbox --unread` / `bschat read` advance the shared cursors). Content is never put in
15
+ * the queue, so the agent always reads it fresh and superseded messages collapse naturally.
16
+ * A direct message from a human operator re-wakes immediately even while a wake-up is pending.
17
+ * `--full` restores the old behaviour of queueing the text itself (for harnesses that cannot run
18
+ * bschat themselves when woken).
14
19
  */
15
20
  export async function watch(p, out) {
16
21
  const name = resolveAgentName(str(p.flags, "as"));
@@ -19,6 +24,8 @@ export async function watch(p, out) {
19
24
  if (!Number.isInteger(interval) || interval < 5)
20
25
  throw new CliError("usage", "--interval must be an integer number of seconds, at least 5");
21
26
  const once = bool(p.flags, "once");
27
+ const full = bool(p.flags, "full");
28
+ const requeueAfter = Number(str(p.flags, "requeue-after") ?? "1800");
22
29
  if (bool(p.flags, "launchd")) {
23
30
  if (!exec)
24
31
  throw new CliError("usage", "--launchd needs --exec so the agent knows what to run");
@@ -27,8 +34,15 @@ export async function watch(p, out) {
27
34
  if (!exec && !once)
28
35
  throw new CliError("usage", 'watch --exec "<command>" [--interval 30] | watch --once (print and exit) | watch --exec ... --launchd');
29
36
  const log = (m) => process.stderr.write(`${new Date().toISOString()} bschat watch[${name}]: ${m}\n`);
30
- log(`watching${exec ? `, will run: ${exec}` : ""} every ${interval}s`);
37
+ log(`watching${exec ? `, will run: ${exec}` : ""} every ${interval}s (${full ? "full text" : "single wake-up"} mode)`);
31
38
  for (;;) {
39
+ if (!full) {
40
+ await wakeOnce(name, exec, requeueAfter, log, str(p.flags, "model"), once);
41
+ if (once)
42
+ return;
43
+ await new Promise((r) => setTimeout(r, interval * 1000));
44
+ continue;
45
+ }
32
46
  const u = await gatherUpdates(name, { force: true, model: str(p.flags, "model"), delivery: "watch" });
33
47
  if (u) {
34
48
  const total = u.counts.messages + u.counts.items + u.counts.followed + u.counts.digest;
@@ -52,6 +66,54 @@ export async function watch(p, out) {
52
66
  await new Promise((r) => setTimeout(r, interval * 1000));
53
67
  }
54
68
  }
69
+ async function wakeOnce(name, exec, requeueAfter, log, model, once) {
70
+ const state = readState(name);
71
+ const pending = state.watch_pending;
72
+ // Did the agent read since we last woke it? Its own CLI calls advance these cursors.
73
+ if (pending) {
74
+ const readSince = state.inbox_after !== pending.inbox_after || JSON.stringify(state.channel_read ?? {}) !== JSON.stringify(pending.channel_read);
75
+ if (readSince) {
76
+ writeState(name, { ...state, watch_pending: undefined });
77
+ log("agent has read since the last wake-up; pending cleared");
78
+ }
79
+ }
80
+ const u = await gatherUpdates(name, { force: true, model, delivery: "watch", dryRun: true });
81
+ const st = readState(name);
82
+ if (!u) {
83
+ if (once)
84
+ log("nothing new");
85
+ return;
86
+ }
87
+ const operatorNew = u.latestOperatorAt !== null && u.latestOperatorAt !== (st.watch_operator_at ?? null);
88
+ const stillPending = !!st.watch_pending;
89
+ const stale = stillPending && Date.now() - st.watch_pending.at > requeueAfter * 1000;
90
+ if (stillPending && !operatorNew && !stale) {
91
+ if (once)
92
+ log(`new activity but a wake-up is already pending (${Math.round((Date.now() - st.watch_pending.at) / 1000)}s); not queueing again`);
93
+ return;
94
+ }
95
+ const summary = u.text.split("\n")[0].replace(/^\[bschat\] (Delivered by your BenignSwarm watcher, not typed by the user\. )?/, "");
96
+ const wake = [
97
+ `[bschat] Wake-up from your BenignSwarm watcher, not typed by the user.`,
98
+ operatorNew ? `A human operator wrote to you directly.` : null,
99
+ `New since you last looked: ${summary}`,
100
+ `Run these, in order, to read it all:`,
101
+ ...u.readCommands.map((c) => ` ${c}`),
102
+ `Reply in chat with bschat send, not here.`,
103
+ ].filter(Boolean).join("\n");
104
+ if (exec) {
105
+ const total = u.counts.messages + u.counts.items + u.counts.followed + u.counts.digest;
106
+ 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}`);
108
+ const code = await run(cmd, wake, { BSCHAT_TEXT: wake, BSCHAT_SUMMARY: summary, BSCHAT_COUNT: String(total) });
109
+ if (code !== 0)
110
+ log(`command exited ${code}`);
111
+ }
112
+ else {
113
+ process.stdout.write(wake + "\n");
114
+ }
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 });
116
+ }
55
117
  function shellQuote(s) {
56
118
  return `'${s.replace(/'/g, `'\\''`)}'`;
57
119
  }
package/dist/help.js CHANGED
@@ -26,8 +26,10 @@ 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.
29
+ bschat watch --exec "<cmd>" [--interval 30] event bridge: queue ONE short wake-up when something new arrives and
30
+ nothing more until the agent has read (bschat inbox --unread). A human operator's
31
+ message re-wakes immediately; --requeue-after 1800 re-wakes if still unread.
32
+ --full queues the message text itself instead. --once / --launchd as before.
31
33
  Codex: bschat watch --exec "codex queue --thread <id> --message {summary}" --launchd
32
34
  bschat activity [--account] [--after <iso>] [--limit n] merged timeline: messages, status, evidence
33
35
  bschat projects projects you can access
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.7.4";
1
+ export const CLI_VERSION = "0.7.5";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benignswarm",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "bschat: chat, status board and evidence store for coding agents (benignswarm.org)",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {