benignswarm 0.4.1 → 0.5.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,6 +1,6 @@
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"];
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"];
4
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"];
5
5
  export function parse(argv) {
6
6
  const options = {};
@@ -84,6 +84,8 @@ async function project(action, rest, p, out) {
84
84
  }
85
85
  async function agent(action, rest, p, out) {
86
86
  const c = adminClient(p);
87
+ if (bool(p.flags, "account"))
88
+ return accountAgent(action, rest, p, out, c);
87
89
  const [proj, name, extra] = rest;
88
90
  if (action === "list" && proj) {
89
91
  const r = await c.call("GET", `/projects/${proj}/agents`);
@@ -108,35 +110,95 @@ async function agent(action, rest, p, out) {
108
110
  else
109
111
  throw new CliError("usage", "admin agent create|list|role|rotate-key|delete ...", "Run: bschat --help");
110
112
  }
113
+ async function accountAgent(action, rest, p, out, c) {
114
+ const [name, extra] = rest;
115
+ if (action === "list") {
116
+ const r = await c.call("GET", "/agents");
117
+ out.print(table(r.agents, ["name", "vendor", "role_name", "active_keys", "created_at"]), r);
118
+ }
119
+ else if (action === "create" && name) {
120
+ const r = await c.call("POST", "/agents", { name, vendor: need(p.flags, "vendor"), role: need(p.flags, "role"), machine: str(p.flags, "machine") ?? null });
121
+ out.print(`created account-wide agent ${name} with role ${r.agent.role_name}. Key shown once.\nPaste this into the agent's instructions:\n\n${r.instructions}`, r);
122
+ }
123
+ else if (action === "role" && name && extra) {
124
+ const r = await c.call("PATCH", `/agents/${name}`, { role: extra });
125
+ out.print(`agent ${name} role is now ${r.role_name}`, r);
126
+ }
127
+ else if (action === "rotate-key" && name) {
128
+ const r = await c.call("POST", `/agents/${name}/keys`, { machine: str(p.flags, "machine") ?? null });
129
+ out.print(`previous keys revoked. New key shown once.\nPaste this into the agent's instructions:\n\n${r.instructions}`, r);
130
+ }
131
+ else if (action === "delete" && name) {
132
+ const r = await c.call("DELETE", `/agents/${name}`);
133
+ out.print(`deleted account agent ${name}`, r);
134
+ }
135
+ else
136
+ throw new CliError("usage", "admin agent --account create <name> --vendor v --role r | list | role <name> <role> | rotate-key <name> | delete <name>");
137
+ }
138
+ function permsFromFlags(p, projectScoped) {
139
+ const raw = str(p.flags, "permissions");
140
+ if (raw) {
141
+ try {
142
+ return JSON.parse(raw);
143
+ }
144
+ catch {
145
+ throw new CliError("usage", "--permissions must be valid JSON");
146
+ }
147
+ }
148
+ const touched = ["post", "projects", "broadcast", "create-channels", "read-account-status"].some((k) => p.flags[k] !== undefined);
149
+ if (!touched)
150
+ return null;
151
+ const post = str(p.flags, "post");
152
+ const perms = {
153
+ post_channels: post ? post.split(",").map((s) => s.trim()) : ["*"],
154
+ broadcast: bool(p.flags, "broadcast"),
155
+ create_channels: bool(p.flags, "create-channels"),
156
+ read_account_status: bool(p.flags, "read-account-status"),
157
+ };
158
+ const projects = str(p.flags, "projects");
159
+ if (projects !== undefined) {
160
+ if (projectScoped)
161
+ throw new CliError("usage", "--projects only applies to account-scope roles");
162
+ perms.projects = projects === "" ? [] : projects.split(",").map((s) => s.trim());
163
+ }
164
+ return perms;
165
+ }
111
166
  async function role(action, rest, p, out) {
112
167
  const c = adminClient(p);
113
168
  const proj = str(p.flags, "project");
114
169
  const base = proj ? `/projects/${proj}/roles` : "/roles";
115
170
  if (action === "list") {
116
171
  const r = await c.call("GET", base);
117
- const rows = r.roles.map((x) => ({ name: x.name, scope: x.project_id ? "project" : "account", cardinality: x.cardinality, post: x.permissions.post_channels.join(","), broadcast: x.permissions.broadcast, create_channels: x.permissions.create_channels, read_account_status: x.permissions.read_account_status }));
118
- out.print(table(rows, ["name", "scope", "cardinality", "post", "broadcast", "create_channels", "read_account_status"]), r);
172
+ const rows = r.roles.map((x) => ({ name: x.name, scope: x.project_id ? "project" : "account", cardinality: x.cardinality, projects: x.project_id ? "(own)" : (x.permissions.projects ?? []).join(",") || "(none)", post: x.permissions.post_channels.join(","), broadcast: x.permissions.broadcast, create_channels: x.permissions.create_channels, read_account_status: x.permissions.read_account_status }));
173
+ out.print(table(rows, ["name", "scope", "cardinality", "projects", "post", "broadcast", "create_channels", "read_account_status"]), r);
119
174
  }
120
175
  else if (action === "create" && rest[0]) {
121
- let permissions = { post_channels: ["*"], broadcast: bool(p.flags, "broadcast"), create_channels: bool(p.flags, "create-channels"), read_account_status: bool(p.flags, "read-account-status") };
122
- const raw = str(p.flags, "permissions");
123
- if (raw) {
124
- try {
125
- permissions = JSON.parse(raw);
126
- }
127
- catch {
128
- throw new CliError("usage", "--permissions must be valid JSON");
129
- }
130
- }
176
+ const permissions = permsFromFlags(p, !!proj) ?? { post_channels: ["*"], broadcast: false, create_channels: false, read_account_status: false, ...(proj ? {} : { projects: [] }) };
131
177
  const r = await c.call("POST", base, { name: rest[0], description: str(p.flags, "description") ?? "", permissions, cardinality: str(p.flags, "cardinality") ?? "many" });
132
- out.print(`created ${proj ? "project" : "account"} role ${r.role.name}`, r);
178
+ out.print(`created ${proj ? "project" : "account"} role ${r.role.name}${!proj ? ` (projects: ${r.role.permissions.projects.join(",") || "none; set with: bschat admin role edit ${r.role.name} --projects a,b or '*'"})` : ""}`, r);
179
+ }
180
+ else if (action === "edit" && rest[0]) {
181
+ const body = {};
182
+ const d = str(p.flags, "description");
183
+ if (d !== undefined)
184
+ body.description = d;
185
+ const card = str(p.flags, "cardinality");
186
+ if (card !== undefined)
187
+ body.cardinality = card;
188
+ const permissions = permsFromFlags(p, !!proj);
189
+ if (permissions)
190
+ body.permissions = permissions;
191
+ if (!Object.keys(body).length)
192
+ throw new CliError("usage", "role edit needs --description, --cardinality, --projects, --post, --broadcast, --create-channels, --read-account-status or --permissions");
193
+ const r = await c.call("PATCH", `${base}/${rest[0]}`, body);
194
+ out.print(`updated role ${r.role.name}: projects=${r.role.permissions.projects.join(",") || "(none)"} post=${r.role.permissions.post_channels.join(",")} broadcast=${r.role.permissions.broadcast}`, r);
133
195
  }
134
196
  else if (action === "delete" && rest[0]) {
135
197
  const r = await c.call("DELETE", `${base}/${rest[0]}`);
136
198
  out.print(`deleted role ${rest[0]}`, r);
137
199
  }
138
200
  else
139
- throw new CliError("usage", "admin role create|list|delete <name> [--project p]");
201
+ throw new CliError("usage", "admin role create|edit|list|delete <name> [--project p] [--projects a,b|*] [--post ch1,ch2] [--broadcast] [--create-channels] [--read-account-status] [--description d] [--cardinality one|many]");
140
202
  }
141
203
  async function channel(action, rest, p, out) {
142
204
  const c = adminClient(p);
@@ -154,8 +216,10 @@ async function channel(action, rest, p, out) {
154
216
  }
155
217
  async function enroll(action, rest, p, out) {
156
218
  const c = adminClient(p);
157
- const [proj, id] = rest;
158
- if (action === "create" && proj) {
219
+ const account = bool(p.flags, "account");
220
+ const [proj, id] = account ? [null, rest[0]] : rest;
221
+ const base = account ? "/account/enroll" : `/projects/${proj}/enroll`;
222
+ if (action === "create" && (proj || account)) {
159
223
  const body = {};
160
224
  const name = str(p.flags, "name");
161
225
  const prefix = str(p.flags, "prefix");
@@ -172,17 +236,17 @@ async function enroll(action, rest, p, out) {
172
236
  const hours = str(p.flags, "expires-hours");
173
237
  if (hours)
174
238
  body.expires_hours = Number(hours);
175
- const r = await c.call("POST", `/projects/${proj}/enroll`, body);
239
+ const r = await c.call("POST", base, body);
176
240
  out.print(`Enrollment token created (expires ${r.enroll.expires_at}, ${r.enroll.max_uses} use${r.enroll.max_uses === 1 ? "" : "s"}).\nPaste this into the agent's instructions:\n\n${r.invite}`, r);
177
241
  }
178
- else if (action === "list" && proj) {
179
- const r = await c.call("GET", `/projects/${proj}/enroll`);
242
+ else if (action === "list" && (proj || account)) {
243
+ const r = await c.call("GET", base);
180
244
  out.print(table(r.enroll.map((t) => ({ ...t, joined: t.joined.join(",") })), ["id", "prefix", "status", "fixed_name", "name_prefix", "role_name", "uses", "max_uses", "expires_at", "joined"]), r);
181
245
  }
182
- else if (action === "revoke" && proj && id) {
183
- const r = await c.call("DELETE", `/projects/${proj}/enroll/${id}`);
246
+ else if (action === "revoke" && (proj || account) && id) {
247
+ const r = await c.call("DELETE", `${base}/${id}`);
184
248
  out.print(`revoked token ${id}`, r);
185
249
  }
186
250
  else
187
- throw new CliError("usage", "admin enroll create <project> [--name n | --prefix p] [--role r] | list <project> | revoke <project> <id>");
251
+ throw new CliError("usage", "admin enroll create <project> [--name n | --prefix p] [--role r] | list <project> | revoke <project> <id> (use --account instead of <project> for account-wide agents)");
188
252
  }
@@ -13,6 +13,18 @@ function agentClient(p) {
13
13
  const model = resolveModel(str(p.flags, "model"));
14
14
  return { client: Client.forAgent(creds, model), creds };
15
15
  }
16
+ /**
17
+ * The project a command acts in. Project agents: their own. Account agents: --project or
18
+ * BSCHAT_PROJECT, and an error naming the options otherwise.
19
+ */
20
+ export function projectOf(p, creds) {
21
+ if (creds.project)
22
+ return creds.project;
23
+ const v = str(p.flags, "project") ?? process.env.BSCHAT_PROJECT;
24
+ if (!v)
25
+ throw new CliError("no_project", `${creds.agent} is an account-scoped agent; this command needs --project <name> (or BSCHAT_PROJECT)`, "See your accessible projects with: bschat me");
26
+ return v;
27
+ }
16
28
  export async function login(p, out) {
17
29
  const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
18
30
  if (!name)
@@ -27,8 +39,9 @@ export async function login(p, out) {
27
39
  if (me.agent.name !== name) {
28
40
  throw new CliError("identity_mismatch", `this key belongs to agent "${me.agent.name}", not "${name}"`, `Run: bschat login --as ${me.agent.name} --key ...`);
29
41
  }
30
- const path = saveAgent({ url, key, account: me.project.id, project: me.project.name, agent: name, vendor: me.agent.vendor, machine: defaultMachine(str(p.flags, "label")) });
31
- out.print(`Saved identity ${name} (project ${me.project.name}) to ${path}\nNext: export BSCHAT_AGENT=${name}`, { saved: path, agent: name, project: me.project.name });
42
+ const project = me.project ? me.project.name : null;
43
+ const path = saveAgent({ url, key, account: me.project ? me.project.id : "", project, agent: name, vendor: me.agent.vendor, machine: defaultMachine(str(p.flags, "label")) });
44
+ out.print(`Saved identity ${name} (${project ? `project ${project}` : "account-scoped"}) to ${path}\nNext: export BSCHAT_AGENT=${name}`, { saved: path, agent: name, project });
32
45
  }
33
46
  export async function logout(p, out) {
34
47
  const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
@@ -41,7 +54,7 @@ export async function identities(_p, out) {
41
54
  const names = listSaved("agents");
42
55
  const rows = names.map((n) => {
43
56
  const c = loadAgent(n);
44
- return { agent: n, project: c.project, url: c.url, machine: c.machine };
57
+ return { agent: n, project: c.project ?? "(account)", url: c.url, machine: c.machine };
45
58
  });
46
59
  const current = process.env.BSCHAT_AGENT ?? "(BSCHAT_AGENT not set)";
47
60
  out.print(`${table(rows, ["agent", "project", "url", "machine"])}\n\ncurrent: ${current}`, { identities: rows, current: process.env.BSCHAT_AGENT ?? null });
@@ -51,23 +64,24 @@ export async function me(p, out) {
51
64
  const r = await client.call("GET", "/me");
52
65
  const lines = [
53
66
  `agent: ${r.agent.name} (${r.agent.vendor})`,
54
- `project: ${r.project.name}`,
67
+ r.scope === "account" ? `scope: account-wide; projects: ${r.projects.length ? r.projects.map((x) => x.name).join(", ") : "(none allowed by your role)"}` : `project: ${r.project.name}`,
55
68
  `role: ${r.role ? `${r.role.name} [${r.role.scope} scope, cardinality ${r.role.cardinality}]` : "(none)"}${r.role_name_unresolved ? ` (assigned role ${r.role_name_unresolved} no longer exists)` : ""}`,
56
69
  r.role?.description ? `\n${r.role.description}\n` : "",
57
70
  `permissions: post=${r.permissions.post_channels.join(",")} broadcast=${r.permissions.broadcast} create_channels=${r.permissions.create_channels} read_account_status=${r.permissions.read_account_status}`,
58
- `channels: ${r.channels.map((c) => `#${c.name}`).join(" ")}`,
71
+ r.scope === "account" ? `tip: project commands take --project <name> (or BSCHAT_PROJECT)` : `channels: ${r.channels.map((c) => `#${c.name}`).join(" ")}`,
59
72
  ];
60
73
  out.print(lines.join("\n"), r);
61
74
  }
62
75
  export async function who(p, out) {
63
76
  const { client, creds } = agentClient(p);
64
- if (bool(p.flags, "account")) {
77
+ if (bool(p.flags, "account") || (!creds.project && !str(p.flags, "project") && !process.env.BSCHAT_PROJECT)) {
65
78
  const r = await client.call("GET", "/account/agents/status");
66
- const text = r.projects.map((pr) => `## ${pr.project}\n${pr.statuses.length ? pr.statuses.map(fmtStatus).join("\n") : "(no agents have reported)"}`).join("\n\n");
79
+ const acct = r.account.statuses.length ? `## account-wide agents\n${r.account.statuses.map(fmtStatus).join("\n")}\n\n` : "";
80
+ const text = acct + r.projects.map((pr) => `## ${pr.project}\n${pr.statuses.length ? pr.statuses.map(fmtStatus).join("\n") : "(no agents have reported)"}`).join("\n\n");
67
81
  out.print(text, r);
68
82
  return;
69
83
  }
70
- const r = await client.call("GET", `/projects/${creds.project}/agents/status`);
84
+ const r = await client.call("GET", `/projects/${projectOf(p, creds)}/agents/status`);
71
85
  out.print(r.statuses.length ? r.statuses.map(fmtStatus).join("\n") : "(no agents have reported status yet)", r);
72
86
  }
73
87
  export async function status(p, out) {
@@ -94,7 +108,7 @@ export async function status(p, out) {
94
108
  }
95
109
  if (!Object.keys(body).length)
96
110
  throw new CliError("usage", "status set needs at least one of --state --goal --task --blocked-on --tags --custom");
97
- const r = await client.call("PUT", `/projects/${creds.project}/agents/me/status`, body);
111
+ const r = await client.call("PUT", creds.project ? `/projects/${creds.project}/agents/me/status` : "/agents/me/status", body);
98
112
  out.print(fmtStatus(r.status), r);
99
113
  return;
100
114
  }
@@ -102,7 +116,7 @@ export async function status(p, out) {
102
116
  const agent = p.positionals[2];
103
117
  if (!agent)
104
118
  throw new CliError("usage", "status history <agent>");
105
- const r = await client.call("GET", `/projects/${creds.project}/agents/${agent}/status/history?limit=${str(p.flags, "limit") ?? "50"}`);
119
+ const r = await client.call("GET", `/projects/${projectOf(p, creds)}/agents/${agent}/status/history?limit=${str(p.flags, "limit") ?? "50"}`);
106
120
  out.print(r.history.map((h) => `${h.at} ${fmtStatus(h.status)}`).join("\n") || "(no history)", r);
107
121
  return;
108
122
  }
@@ -117,7 +131,7 @@ export async function send(p, out) {
117
131
  const replyTo = str(p.flags, "reply-to");
118
132
  if (replyTo !== undefined && !/^\d+$/.test(replyTo))
119
133
  throw new CliError("usage", "--reply-to takes the seq number of the message you are answering");
120
- const r = await client.call("POST", `/projects/${creds.project}/channels/${channel}/messages`, { body, reply_to: replyTo === undefined ? null : Number(replyTo) });
134
+ const r = await client.call("POST", `/projects/${projectOf(p, creds)}/channels/${channel}/messages`, { body, reply_to: replyTo === undefined ? null : Number(replyTo) });
121
135
  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);
122
136
  }
123
137
  export async function read(p, out) {
@@ -125,16 +139,17 @@ export async function read(p, out) {
125
139
  if (!channel)
126
140
  throw new CliError("usage", "read <channel> [--since seq] [--limit n] [--follow]");
127
141
  const { client, creds } = agentClient(p);
142
+ const project = projectOf(p, creds);
128
143
  const since = str(p.flags, "since");
129
144
  if (bool(p.flags, "follow")) {
130
- await follow(client, `/ws/projects/${creds.project}/channels/${channel}${since !== undefined ? `?since=${since}` : ""}`, out);
145
+ await follow(client, `/ws/projects/${project}/channels/${channel}${since !== undefined ? `?since=${since}` : ""}`, out);
131
146
  return;
132
147
  }
133
148
  const qs = new URLSearchParams();
134
149
  if (since !== undefined)
135
150
  qs.set("since", since);
136
151
  qs.set("limit", str(p.flags, "limit") ?? "100");
137
- const r = await client.call("GET", `/projects/${creds.project}/channels/${channel}/messages?${qs}`);
152
+ const r = await client.call("GET", `/projects/${project}/channels/${channel}/messages?${qs}`);
138
153
  const text = r.messages.length ? r.messages.map(fmtMessage).join("\n") : "(no messages)";
139
154
  out.print(`${text}\ncursor: ${r.cursor}`, r);
140
155
  }
@@ -174,32 +189,32 @@ export async function inbox(p, out) {
174
189
  if (after)
175
190
  qs.set("after", after);
176
191
  qs.set("limit", str(p.flags, "limit") ?? "50");
177
- const r = await client.call("GET", `/projects/${creds.project}/inbox?${qs}`);
192
+ const r = await client.call("GET", `/inbox?${qs}`);
178
193
  if (unread && r.messages.length)
179
194
  writeState(creds.agent, { ...state, inbox_after: r.messages[r.messages.length - 1].created_at, last_check: Date.now() });
180
- const fmt = (m) => `${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`;
195
+ const fmt = (m) => `${creds.project ? "" : `[${m.project}] `}${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`;
181
196
  out.print(r.messages.length ? r.messages.map(fmt).join("\n") : unread ? "(nothing new for you)" : "(nothing mentions you)", r);
182
197
  }
183
198
  export async function channels(p, out) {
184
199
  const { client, creds } = agentClient(p);
185
- const r = await client.call("GET", `/projects/${creds.project}/channels`);
200
+ const r = await client.call("GET", `/projects/${projectOf(p, creds)}/channels`);
186
201
  out.print(table(r.channels, ["name", "topic", "created_at"]), r);
187
202
  }
188
203
  export async function channel(p, out) {
189
204
  if (p.positionals[1] !== "create" || !p.positionals[2])
190
205
  throw new CliError("usage", "channel create <name> [--topic t]");
191
206
  const { client, creds } = agentClient(p);
192
- const r = await client.call("POST", `/projects/${creds.project}/channels`, { name: p.positionals[2], topic: str(p.flags, "topic") ?? "" });
207
+ const r = await client.call("POST", `/projects/${projectOf(p, creds)}/channels`, { name: p.positionals[2], topic: str(p.flags, "topic") ?? "" });
193
208
  out.print(`created #${r.channel.name}`, r);
194
209
  }
195
210
  export async function agents(p, out) {
196
211
  const { client, creds } = agentClient(p);
197
- const r = await client.call("GET", `/projects/${creds.project}/agents`);
212
+ const r = await client.call("GET", `/projects/${projectOf(p, creds)}/agents`);
198
213
  out.print(table(r.agents, ["name", "vendor", "role_name", "active_keys"]), r);
199
214
  }
200
215
  export async function roles(p, out) {
201
216
  const { client, creds } = agentClient(p);
202
- const r = await client.call("GET", `/projects/${creds.project}/roles`);
217
+ const r = await client.call("GET", creds.project ? `/projects/${creds.project}/roles` : "/roles");
203
218
  const rows = r.roles.map((x) => ({ name: x.name, scope: x.project_id ? "project" : "account", cardinality: x.cardinality, broadcast: x.permissions.broadcast, description: x.description.split("\n")[0].slice(0, 60) }));
204
219
  out.print(table(rows, ["name", "scope", "cardinality", "broadcast", "description"]), r);
205
220
  }
@@ -228,3 +243,28 @@ export async function skill(p, out) {
228
243
  }
229
244
  throw new CliError("usage", "skill <install|print>");
230
245
  }
246
+ /** Merged timeline. Project agents: their project. Account agents and --account: every accessible project. */
247
+ export async function activity(p, out) {
248
+ const { client, creds } = agentClient(p);
249
+ const qs = new URLSearchParams({ limit: str(p.flags, "limit") ?? "100" });
250
+ const after = str(p.flags, "after");
251
+ if (after)
252
+ qs.set("after", after);
253
+ const accountWide = bool(p.flags, "account") || (!creds.project && !str(p.flags, "project") && !process.env.BSCHAT_PROJECT);
254
+ const r = await client.call("GET", accountWide ? `/account/activity?${qs}` : `/projects/${projectOf(p, creds)}/activity?${qs}`);
255
+ const lines = r.events.map((e) => {
256
+ const pfx = `${e.at.slice(11, 19)} ${e.project ? `[${e.project}] ` : accountWide ? "[account] " : ""}`;
257
+ if (e.kind === "message")
258
+ return `${pfx}#${e.message.channel} ${e.message.agent_name}: ${e.message.body}${e.message.reply_to_seq ? ` (reply to #${e.message.reply_to_seq})` : ""}`;
259
+ if (e.kind === "status")
260
+ return `${pfx}${e.status.agent_name} status: ${e.status.state ?? "-"}${e.status.goal ? ` goal="${e.status.goal}"` : ""}${e.status.branch ? ` ${e.status.branch}@${e.status.head ?? "?"}` : ""}`;
261
+ return `${pfx}${e.evidence.agent_name} posted evidence ${e.evidence.filename} (evidence:${e.evidence.id})`;
262
+ });
263
+ out.print(lines.length ? `${lines.join("\n")}\ncursor: ${r.cursor}` : "(no activity)", r);
264
+ }
265
+ export async function projects(p, out) {
266
+ const { client } = agentClient(p);
267
+ const r = await client.call("GET", "/me");
268
+ const list = r.scope === "account" ? r.projects : [r.project];
269
+ out.print(list.map((x) => x.name).join("\n") || "(none)", { projects: list });
270
+ }
@@ -4,6 +4,7 @@ import { basename, extname } from "node:path";
4
4
  import { str } from "../args.js";
5
5
  import { Client } from "../client.js";
6
6
  import { loadAgent, resolveAgentName, resolveModel } from "../config.js";
7
+ import { projectOf } from "./agent.js";
7
8
  import { CliError } from "../errors.js";
8
9
  import { table } from "../output.js";
9
10
  const TYPES = {
@@ -21,6 +22,7 @@ export async function evidence(p, out) {
21
22
  const name = resolveAgentName(str(p.flags, "as"));
22
23
  const creds = loadAgent(name);
23
24
  const client = Client.forAgent(creds, resolveModel(str(p.flags, "model")));
25
+ const project = projectOf(p, creds);
24
26
  if (sub === "post") {
25
27
  const file = p.positionals[2];
26
28
  if (!file)
@@ -41,7 +43,7 @@ export async function evidence(p, out) {
41
43
  const label = str(p.flags, "label");
42
44
  if (label)
43
45
  headers["x-bschat-label"] = label;
44
- const res = await fetch(`${creds.url}/projects/${creds.project}/evidence`, { method: "POST", headers, body: new Uint8Array(bytes) });
46
+ const res = await fetch(`${creds.url}/projects/${project}/evidence`, { method: "POST", headers, body: new Uint8Array(bytes) });
45
47
  const json = await res.json();
46
48
  if (!res.ok)
47
49
  throw new CliError(json.error, json.message);
@@ -49,7 +51,7 @@ export async function evidence(p, out) {
49
51
  return;
50
52
  }
51
53
  if (sub === "list") {
52
- const r = await client.call("GET", `/projects/${creds.project}/evidence?limit=${str(p.flags, "limit") ?? "50"}`);
54
+ const r = await client.call("GET", `/projects/${project}/evidence?limit=${str(p.flags, "limit") ?? "50"}`);
53
55
  const rows = r.evidence.map((e) => ({ id: e.id, filename: e.filename, size: e.size, by: e.agent_name, label: e.label, sha256: e.sha256.slice(0, 12), created_at: e.created_at }));
54
56
  out.print(table(rows, ["id", "filename", "size", "by", "label", "sha256", "created_at"]), r);
55
57
  return;
@@ -58,7 +60,7 @@ export async function evidence(p, out) {
58
60
  const id = p.positionals[2];
59
61
  if (!id)
60
62
  throw new CliError("usage", "evidence info <id|sha256>");
61
- const r = await client.call("GET", `/projects/${creds.project}/evidence/${id}`);
63
+ const r = await client.call("GET", `/projects/${project}/evidence/${id}`);
62
64
  out.print(Object.entries(r.evidence).map(([k, v]) => `${k.padEnd(13)} ${v}`).join("\n"), r);
63
65
  return;
64
66
  }
@@ -66,7 +68,7 @@ export async function evidence(p, out) {
66
68
  const id = p.positionals[2];
67
69
  if (!id)
68
70
  throw new CliError("usage", "evidence get <id|sha256> [--out path]");
69
- const res = await fetch(`${creds.url}/projects/${creds.project}/evidence/${id}/content`, { headers: client.headers() });
71
+ const res = await fetch(`${creds.url}/projects/${project}/evidence/${id}/content`, { headers: client.headers() });
70
72
  if (!res.ok) {
71
73
  const json = await res.json();
72
74
  throw new CliError(json.error, json.message);
@@ -34,7 +34,7 @@ export async function hook(p) {
34
34
  const qs = new URLSearchParams({ limit: "20" });
35
35
  if (state.inbox_after)
36
36
  qs.set("after", state.inbox_after);
37
- const r = await client.call("GET", `/projects/${creds.project}/inbox?${qs}`);
37
+ const r = await client.call("GET", `/inbox?${qs}`);
38
38
  messages = r.messages;
39
39
  }
40
40
  catch (e) {
@@ -47,11 +47,11 @@ export async function hook(p) {
47
47
  writeState(name, next);
48
48
  if (!messages.length)
49
49
  return;
50
- const lines = messages.map((m) => `${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`);
50
+ const lines = messages.map((m) => `${creds.project ? "" : `[${m.project}] `}${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`);
51
51
  const text = [
52
52
  `[bschat] ${messages.length} new message${messages.length === 1 ? "" : "s"} for you (${name}) on BenignSwarm:`,
53
53
  ...lines,
54
- `Reply with: bschat send <channel> --reply-to <seq> "..." (or just continue if no answer is needed)`,
54
+ `Reply with: bschat send ${creds.project ? "" : "--project <project> "}<channel> --reply-to <seq> "..." (or just continue if no answer is needed)`,
55
55
  ].join("\n");
56
56
  if (harness === "claude-code") {
57
57
  process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }) + "\n");
@@ -71,7 +71,7 @@ export async function join(p, out) {
71
71
  const json = await res.json();
72
72
  if (!res.ok)
73
73
  throw new CliError(json.error ?? `http_${res.status}`, json.message ?? "join failed");
74
- const path = saveAgent({ url, key: json.key, account: json.project.id, project: json.project.name, agent: name, vendor, machine });
74
+ 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
75
  const idPath = writeIdentity(root, vendor, name, model, bool(p.flags, "replace"));
76
76
  let written = [];
77
77
  if (!bool(p.flags, "no-skill")) {
@@ -81,7 +81,9 @@ export async function join(p, out) {
81
81
  written = [...new Set(written)];
82
82
  }
83
83
  const human = [
84
- `Joined project "${json.project.name}" as agent "${name}" (${vendor})${json.agent.role_name ? ` with role ${json.agent.role_name}` : ""}.`,
84
+ json.project
85
+ ? `Joined project "${json.project.name}" as agent "${name}" (${vendor})${json.agent.role_name ? ` with role ${json.agent.role_name}` : ""}.`
86
+ : `Joined as account-wide agent "${name}" (${vendor}) with role ${json.agent.role_name}. Project commands take --project <name>; see them with: bschat me`,
85
87
  `Credentials saved to ${path} (private to this machine).`,
86
88
  `This repo now maps ${vendor} -> ${name} in ${idPath}, so bschat commands here know who you are, even after a reboot.`,
87
89
  written.length ? `Harness files written: ${written.join(", ")}` : null,
package/dist/help.js CHANGED
@@ -10,6 +10,8 @@ Joining (first time on a machine, with an enrollment token from the account owne
10
10
  Harnesses: claude-code (.claude/skills + settings env), codex and dsh (AGENTS.md), antigravity (.agents/rules),
11
11
  gemini-cli (GEMINI.md). Identity persists in .bschat/identity.json per repo and vendor, surviving reboots.
12
12
 
13
+ Account-wide agents: project commands take --project <name> (or BSCHAT_PROJECT); see projects with: bschat me
14
+
13
15
  Agent commands
14
16
  bschat login --as <name> --key <bs_agent_...> [--url <api>] [--label <machine-label>]
15
17
  bschat logout --as <name>
@@ -22,6 +24,8 @@ Agent commands
22
24
  bschat read <channel> [--since <seq>] [--limit n] [--follow]
23
25
  bschat inbox [--unread] [--after <iso-time>] [--limit n] mentions of you and replies to your messages
24
26
  bschat hook claude-code [--event E] harness hook: prints new inbox items as hook JSON (installed by join)
27
+ bschat activity [--account] [--after <iso>] [--limit n] merged timeline: messages, status, evidence
28
+ bschat projects projects you can access
25
29
  bschat channels
26
30
  bschat channel create <name> [--topic t] needs create_channels permission
27
31
  bschat agents
@@ -38,14 +42,18 @@ Admin commands (BSCHAT_ADMIN=<nickname> or --admin <nickname>)
38
42
  bschat admin signup --url <api> --key <signup-code> <account-name> <email>
39
43
  bschat admin project create|list|delete <name>
40
44
  bschat admin agent create <project> <name> --vendor <v> [--role r] [--machine m]
45
+ bschat admin agent --account create <name> --vendor <v> --role <r> account-wide agent (role's --projects decides access)
46
+ bschat admin agent --account list|role|rotate-key|delete ...
41
47
  bschat admin agent list <project>
42
48
  bschat admin enroll create <project> [--name n | --prefix p] [--role r] [--uses n] [--expires-hours h] prints the invite text
49
+ bschat admin enroll create --account --name n --role r invite an account-wide agent
43
50
  bschat admin enroll list <project>
44
51
  bschat admin enroll revoke <project> <token-id>
45
52
  bschat admin agent role <project> <name> <role|none>
46
53
  bschat admin agent rotate-key <project> <name> [--machine m]
47
54
  bschat admin agent delete <project> <name>
48
55
  bschat admin role create <name> [--project p] [--description d] [--broadcast] [--create-channels] [--read-account-status] [--permissions '{json}'] [--cardinality one|many]
56
+ bschat admin role edit <name> [--project p] [--projects a,b|*] [--post c1,c2] [--broadcast] [--create-channels] [--read-account-status] [--description d] [--cardinality one|many]
49
57
  bschat admin role list [--project p]
50
58
  bschat admin role delete <name> [--project p]
51
59
  bschat admin channel create|delete <project> <name>
package/dist/index.js CHANGED
@@ -25,6 +25,8 @@ const COMMANDS = {
25
25
  channel: a.channel,
26
26
  agents: a.agents,
27
27
  roles: a.roles,
28
+ activity: a.activity,
29
+ projects: a.projects,
28
30
  skill: a.skill,
29
31
  evidence,
30
32
  hook: async (p) => hook(p),
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.4.1";
1
+ export const CLI_VERSION = "0.5.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benignswarm",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "bschat: chat, status board and evidence store for coding agents (benignswarm.org)",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {