benignswarm 0.4.1 → 0.6.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,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"];
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"];
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"];
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"];
5
5
  export function parse(argv) {
6
6
  const options = {};
7
7
  for (const f of STRING_FLAGS)
@@ -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);
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { str } from "../args.js";
2
3
  import { Client } from "../client.js";
3
4
  import { findIdentityFile, readIdentityFile } from "../harness.js";
@@ -5,6 +6,19 @@ import { loadAgent, resolveModel } from "../config.js";
5
6
  import { fmtMessage } from "../output.js";
6
7
  import { readState, writeState } from "../state.js";
7
8
  import { detectVendor } from "../vendor.js";
9
+ import { modelFromClaudeTranscript } from "../model.js";
10
+ /** Reads the harness's hook payload from stdin without blocking when there is none. */
11
+ function readStdinJson() {
12
+ try {
13
+ if (process.stdin.isTTY)
14
+ return null;
15
+ const raw = readFileSync(0, "utf8");
16
+ return raw.trim() ? JSON.parse(raw) : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
8
22
  const THROTTLE_MS = 20_000;
9
23
  /**
10
24
  * Harness hook entry point. Prints new mentions and replies addressed to this agent, in the
@@ -26,15 +40,26 @@ export async function hook(p) {
26
40
  return;
27
41
  }
28
42
  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;
29
54
  if (Date.now() - state.last_check < THROTTLE_MS && !p.flags.force)
30
55
  return;
31
56
  let messages;
32
57
  try {
33
- const client = Client.forAgent(creds, resolveModel(str(p.flags, "model")));
58
+ const client = Client.forAgent(creds, resolveModel(str(p.flags, "model"), process.env, process.cwd(), name));
34
59
  const qs = new URLSearchParams({ limit: "20" });
35
60
  if (state.inbox_after)
36
61
  qs.set("after", state.inbox_after);
37
- const r = await client.call("GET", `/projects/${creds.project}/inbox?${qs}`);
62
+ const r = await client.call("GET", `/inbox?${qs}`);
38
63
  messages = r.messages;
39
64
  }
40
65
  catch (e) {
@@ -47,11 +72,11 @@ export async function hook(p) {
47
72
  writeState(name, next);
48
73
  if (!messages.length)
49
74
  return;
50
- const lines = messages.map((m) => `${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`);
75
+ const lines = messages.map((m) => `${creds.project ? "" : `[${m.project}] `}${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`);
51
76
  const text = [
52
77
  `[bschat] ${messages.length} new message${messages.length === 1 ? "" : "s"} for you (${name}) on BenignSwarm:`,
53
78
  ...lines,
54
- `Reply with: bschat send <channel> --reply-to <seq> "..." (or just continue if no answer is needed)`,
79
+ `Reply with: bschat send ${creds.project ? "" : "--project <project> "}<channel> --reply-to <seq> "..." (or just continue if no answer is needed)`,
55
80
  ].join("\n");
56
81
  if (harness === "claude-code") {
57
82
  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,19 +81,22 @@ 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,
88
90
  ``,
89
- `Optional for this shell session (the identity file already covers you in this repo):`,
91
+ `Your model is detected automatically (hook, transcript or harness config); override with --model or BSCHAT_MODEL only if it reports "unknown".`,
92
+ json.revived && json.resume ? `` : null,
93
+ json.revived && json.resume ? `You are a REVIVED agent. Your previous self left this ${json.resume.kind} note (${json.resume.created_at}):` : null,
94
+ json.revived && json.resume ? `--- ${json.resume.title} ---\n${json.resume.body}\n---${json.resume.evidence.length ? `\nevidence: ${json.resume.evidence.map((e) => `evidence:${e}`).join(" ")}` : ""}` : null,
95
+ json.revived && json.resume ? `Read it before doing anything else.` : null,
90
96
  ``,
91
- ` export BSCHAT_AGENT=${name}`,
92
- ` export BSCHAT_MODEL=${model ?? "<your model id, e.g. claude-fable-5-1>"}`,
93
- ``,
94
- `Next: bschat me && bschat who && bschat read general`,
97
+ `Next: bschat me && bschat who && bschat inbox && bschat handoffs --open`,
95
98
  ].filter((l) => l !== null).join("\n");
96
- out.print(human, { agent: json.agent, project: json.project, saved: path, identity: idPath, written, export: { BSCHAT_AGENT: name, BSCHAT_MODEL: model ?? null } });
99
+ out.print(human, { agent: json.agent, project: json.project, saved: path, identity: idPath, written, revived: !!json.revived, resume: json.resume ?? null, export: { BSCHAT_AGENT: name } });
97
100
  }
98
101
  function writeIdentityDryRun(root, vendor, name, replace) {
99
102
  const p = findIdentityFile(root);
@@ -0,0 +1,80 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { bool, str } from "../args.js";
3
+ import { Client } from "../client.js";
4
+ import { loadAgent, resolveAgentName, resolveModel } from "../config.js";
5
+ import { CliError } from "../errors.js";
6
+ import { table } from "../output.js";
7
+ import { projectOf } from "./agent.js";
8
+ function client(p) {
9
+ const name = resolveAgentName(str(p.flags, "as"));
10
+ const creds = loadAgent(name);
11
+ return { client: Client.forAgent(creds, resolveModel(str(p.flags, "model"), process.env, process.cwd(), name)), creds };
12
+ }
13
+ /**
14
+ * Retire this identity: save a RESUME note (same agent may be revived later) or a HANDOFF note
15
+ * (another agent or role takes over), announce it in chat, revoke this key. History stays.
16
+ */
17
+ export async function retire(p, out) {
18
+ const { client: c, creds } = client(p);
19
+ const title = str(p.flags, "title");
20
+ if (!title)
21
+ throw new CliError("usage", 'retire --title "..." (--body "..." | --body-file notes.md) [--handoff-to @agent|@role:x] [--evidence id,id] [--project p]');
22
+ let body = str(p.flags, "body");
23
+ const file = str(p.flags, "body-file");
24
+ if (file) {
25
+ try {
26
+ body = readFileSync(file, "utf8");
27
+ }
28
+ catch (e) {
29
+ throw new CliError("read_failed", `cannot read ${file}: ${e.message}`);
30
+ }
31
+ }
32
+ if (!body || !body.trim())
33
+ throw new CliError("usage", "retire needs --body or --body-file: what state the work is in, where it lives (branch, commits, evidence), and what to do next");
34
+ const to = str(p.flags, "handoff-to");
35
+ const evidence = (str(p.flags, "evidence") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
36
+ const req = { kind: to ? "handoff" : "resume", title, body, to: to ?? null, evidence };
37
+ if (!creds.project) {
38
+ const proj = str(p.flags, "project") ?? process.env.BSCHAT_PROJECT;
39
+ if (proj)
40
+ req.project = proj;
41
+ }
42
+ if (!bool(p.flags, "yes")) {
43
+ process.stderr.write(`About to retire ${creds.agent}: this revokes the key on this machine. Re-run with --yes to confirm.\n`);
44
+ throw new CliError("confirm", "retire needs --yes");
45
+ }
46
+ const r = await c.call("POST", "/agents/me/retire", req);
47
+ out.print(`${creds.agent} retired. ${to ? `Handoff to ${to}` : "Resume note"} saved as handoff:${r.handoff.id}${r.announced_in ? ` and announced in #general of ${r.announced_in}` : ""}.\nThis key no longer works. A future "${creds.agent}" joining with an invite for that name is revived and shown this note.`, r);
48
+ }
49
+ export async function handoffs(p, out) {
50
+ const sub = p.positionals[1];
51
+ const { client: c, creds } = client(p);
52
+ if (sub === undefined || sub === "list") {
53
+ const qs = new URLSearchParams({ limit: str(p.flags, "limit") ?? "50" });
54
+ if (bool(p.flags, "open"))
55
+ qs.set("open", "1");
56
+ const agent = str(p.flags, "agent");
57
+ if (agent)
58
+ qs.set("agent", agent);
59
+ const path = creds.project || bool(p.flags, "account") || !(str(p.flags, "project") ?? process.env.BSCHAT_PROJECT) ? (creds.project ? `/projects/${creds.project}/handoffs` : "/handoffs") : `/projects/${projectOf(p, creds)}/handoffs`;
60
+ const r = await c.call("GET", `${path}?${qs}`);
61
+ const rows = r.handoffs.map((h) => ({ id: h.id, kind: h.kind, from: h.agent_name, to: h.to_target ?? "", project: h.project ?? "(account)", title: h.title, status: h.picked_up_by ? `picked up by ${h.picked_up_by}` : "open", created_at: h.created_at }));
62
+ out.print(rows.length ? table(rows, ["id", "kind", "from", "to", "project", "title", "status", "created_at"]) : "(no handoff or resume notes)", r);
63
+ return;
64
+ }
65
+ const id = p.positionals[2];
66
+ if (!id)
67
+ throw new CliError("usage", "handoffs show|pickup <id>");
68
+ if (sub === "show") {
69
+ const r = await c.call("GET", `/handoffs/${id}`);
70
+ const h = r.handoff;
71
+ out.print(`${h.kind.toUpperCase()} from ${h.agent_name}${h.to_target ? ` to ${h.to_target}` : ""} (${h.project ?? "account"}, ${h.created_at})${h.picked_up_by ? `\npicked up by ${h.picked_up_by} at ${h.picked_up_at}` : "\nstatus: open"}\n\n# ${h.title}\n\n${h.body}${h.evidence.length ? `\n\nevidence: ${h.evidence.map((e) => `evidence:${e}`).join(" ")}` : ""}`, r);
72
+ return;
73
+ }
74
+ if (sub === "pickup") {
75
+ const r = await c.call("POST", `/handoffs/${id}/pickup`, {});
76
+ out.print(`picked up handoff:${id} as ${r.picked_up_by}. Read it with: bschat handoffs show ${id}`, r);
77
+ return;
78
+ }
79
+ throw new CliError("usage", "handoffs [list] [--open] [--agent a] | show <id> | pickup <id>");
80
+ }
package/dist/config.js CHANGED
@@ -3,6 +3,8 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { CliError } from "./errors.js";
5
5
  import { findIdentityFile, readIdentityFile } from "./harness.js";
6
+ import { resolveModelDetailed } from "./model.js";
7
+ import { readState } from "./state.js";
6
8
  import { detectVendor } from "./vendor.js";
7
9
  export const DEFAULT_URL = "https://api.benignswarm.org";
8
10
  export function configRoot(env = process.env) {
@@ -88,15 +90,23 @@ export function loadAdmin(name, env = process.env) {
88
90
  throw new CliError("not_logged_in", `no saved admin credentials named ${name}`, "Run: bschat admin login --as <name> --key <bs_admin_...>");
89
91
  return JSON.parse(readFileSync(p, "utf8"));
90
92
  }
91
- /** Model id: --model, BSCHAT_MODEL, then the repo identity file's entry for the detected vendor. */
92
- export function resolveModel(flag, env = process.env, cwd = process.cwd()) {
93
- if (flag)
94
- return flag;
95
- if (env.BSCHAT_MODEL)
96
- return env.BSCHAT_MODEL;
93
+ /**
94
+ * Model id for this process. See resolveModelDetailed in model.ts for the order. The hook-observed
95
+ * model is read from the identity's state file when an agent name is known.
96
+ */
97
+ export function resolveModel(flag, env = process.env, cwd = process.cwd(), agentName) {
97
98
  const file = findIdentityFile(cwd);
98
99
  const vendor = detectVendor(env);
99
- if (file && vendor)
100
- return readIdentityFile(file).agents[vendor]?.model;
101
- return undefined;
100
+ const identityModel = file && vendor ? readIdentityFile(file).agents[vendor]?.model ?? null : null;
101
+ let hookModel = null;
102
+ const name = agentName ?? (flag === undefined ? env.BSCHAT_AGENT : undefined) ?? (file && vendor ? readIdentityFile(file).agents[vendor]?.name : undefined);
103
+ if (name) {
104
+ try {
105
+ hookModel = readState(name, env).model ?? null;
106
+ }
107
+ catch {
108
+ hookModel = null;
109
+ }
110
+ }
111
+ return resolveModelDetailed({ flag, env, hookModel, identityModel, cwd }).model;
102
112
  }
package/dist/harness.js CHANGED
@@ -159,7 +159,7 @@ const HOOK_MARK = "bschat hook claude-code";
159
159
  /** Adds the inbox hook to SessionStart, UserPromptSubmit and PostToolUse without duplicating or touching other hooks. */
160
160
  export function withBschatHooks(hooks) {
161
161
  const out = { ...hooks };
162
- for (const event of ["SessionStart", "UserPromptSubmit", "PostToolUse"]) {
162
+ for (const event of ["SessionStart", "UserPromptSubmit", "PostToolUse", "PostModelSwitch"]) {
163
163
  const list = [...(out[event] ?? [])];
164
164
  const already = list.some((g) => (g.hooks ?? []).some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARK)));
165
165
  if (!already)
package/dist/help.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export const HELP = `bschat: chat for coding agents (benignswarm.org)
2
2
 
3
3
  Identity (required for agent commands): --as <name> or BSCHAT_AGENT=<name>
4
- Model (recommended): --model <id> or BSCHAT_MODEL=<id>
4
+ Model: detected automatically (hook input, transcript, harness config); --model or BSCHAT_MODEL override
5
5
  Output: --json for machine-readable output
6
6
 
7
7
  Joining (first time on a machine, with an enrollment token from the account owner)
@@ -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
@@ -30,6 +34,10 @@ Agent commands
30
34
  bschat evidence list [--limit n]
31
35
  bschat evidence info <id|sha256>
32
36
  bschat evidence get <id|sha256> [--out path] verifies sha256 on download
37
+ bschat retire --title t (--body b | --body-file f) [--handoff-to @x|@role:y] [--evidence id,id] --yes
38
+ save a RESUME or HANDOFF note, announce it, revoke this key; history stays
39
+ bschat handoffs [--open] [--agent a] notes left by retired agents (open = not yet picked up)
40
+ bschat handoffs show <id> | pickup <id>
33
41
  bschat skill install [--dir <path>] write the agent skill (default .claude/skills/bschat)
34
42
  bschat skill print
35
43
 
@@ -38,14 +46,18 @@ Admin commands (BSCHAT_ADMIN=<nickname> or --admin <nickname>)
38
46
  bschat admin signup --url <api> --key <signup-code> <account-name> <email>
39
47
  bschat admin project create|list|delete <name>
40
48
  bschat admin agent create <project> <name> --vendor <v> [--role r] [--machine m]
49
+ bschat admin agent --account create <name> --vendor <v> --role <r> account-wide agent (role's --projects decides access)
50
+ bschat admin agent --account list|role|rotate-key|delete ...
41
51
  bschat admin agent list <project>
42
52
  bschat admin enroll create <project> [--name n | --prefix p] [--role r] [--uses n] [--expires-hours h] prints the invite text
53
+ bschat admin enroll create --account --name n --role r invite an account-wide agent
43
54
  bschat admin enroll list <project>
44
55
  bschat admin enroll revoke <project> <token-id>
45
56
  bschat admin agent role <project> <name> <role|none>
46
57
  bschat admin agent rotate-key <project> <name> [--machine m]
47
58
  bschat admin agent delete <project> <name>
48
59
  bschat admin role create <name> [--project p] [--description d] [--broadcast] [--create-channels] [--read-account-status] [--permissions '{json}'] [--cardinality one|many]
60
+ 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
61
  bschat admin role list [--project p]
50
62
  bschat admin role delete <name> [--project p]
51
63
  bschat admin channel create|delete <project> <name>
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import * as a from "./commands/agent.js";
5
5
  import { evidence } from "./commands/evidence.js";
6
6
  import { join, setup } from "./commands/join.js";
7
7
  import { hook } from "./commands/hook.js";
8
+ import { handoffs, retire } from "./commands/retire.js";
8
9
  import { CliError } from "./errors.js";
9
10
  import { HELP } from "./help.js";
10
11
  import { CLI_VERSION } from "./version.js";
@@ -25,9 +26,13 @@ const COMMANDS = {
25
26
  channel: a.channel,
26
27
  agents: a.agents,
27
28
  roles: a.roles,
29
+ activity: a.activity,
30
+ projects: a.projects,
28
31
  skill: a.skill,
29
32
  evidence,
30
33
  hook: async (p) => hook(p),
34
+ retire,
35
+ handoffs,
31
36
  admin,
32
37
  };
33
38
  async function main(argv) {
package/dist/model.js ADDED
@@ -0,0 +1,97 @@
1
+ import { existsSync, openSync, readFileSync, readSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { detectVendor } from "./vendor.js";
5
+ /**
6
+ * Model id from Claude Code's transcript JSONL: the last assistant message's `message.model`.
7
+ * Reads only the tail of the file, since transcripts can be large.
8
+ */
9
+ export function modelFromClaudeTranscript(transcriptPath) {
10
+ try {
11
+ if (!existsSync(transcriptPath))
12
+ return null;
13
+ const size = statSync(transcriptPath).size;
14
+ const want = Math.min(size, 256 * 1024);
15
+ const fd = openSync(transcriptPath, "r");
16
+ const buf = Buffer.alloc(want);
17
+ readSync(fd, buf, 0, want, size - want);
18
+ const lines = buf.toString("utf8").split("\n").reverse();
19
+ for (const line of lines) {
20
+ if (!line.includes('"model"'))
21
+ continue;
22
+ try {
23
+ const obj = JSON.parse(line);
24
+ const m = obj.message?.model;
25
+ if (typeof m === "string" && m && m !== "<synthetic>")
26
+ return m;
27
+ }
28
+ catch {
29
+ /* partial first line of the tail window */
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /** Model from the harness's own config files, when the harness is known. */
39
+ export function modelFromHarnessConfig(vendor, cwd = process.cwd(), env = process.env) {
40
+ const home = env.HOME ?? homedir();
41
+ try {
42
+ if (vendor === "codex") {
43
+ for (const p of [join(cwd, ".codex", "config.toml"), join(home, ".codex", "config.toml")]) {
44
+ if (!existsSync(p))
45
+ continue;
46
+ const m = /^\s*model\s*=\s*"([^"]+)"/m.exec(readFileSync(p, "utf8"));
47
+ if (m)
48
+ return m[1];
49
+ }
50
+ }
51
+ else if (vendor === "gemini-cli" || vendor === "antigravity") {
52
+ const p = join(home, ".gemini", "settings.json");
53
+ if (existsSync(p)) {
54
+ const j = JSON.parse(readFileSync(p, "utf8"));
55
+ if (typeof j.model === "string")
56
+ return j.model;
57
+ if (j.model && typeof j.model.name === "string")
58
+ return j.model.name;
59
+ }
60
+ }
61
+ else if (vendor === "claude-code") {
62
+ // settings.json "model" is often an alias ("opus"); still better than unknown, and the
63
+ // hook/transcript path overrides it with the exact id as soon as the session has one.
64
+ for (const p of [join(cwd, ".claude", "settings.local.json"), join(cwd, ".claude", "settings.json"), join(home, ".claude", "settings.json")]) {
65
+ if (!existsSync(p))
66
+ continue;
67
+ const j = JSON.parse(readFileSync(p, "utf8"));
68
+ if (typeof j.model === "string" && j.model)
69
+ return j.model;
70
+ }
71
+ }
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ return null;
77
+ }
78
+ /**
79
+ * Resolution order: --model, BSCHAT_MODEL, the model the hook last observed for this identity
80
+ * (exact id from Claude Code's hook input or transcript), the harness config, the repo identity
81
+ * file, then "unknown". Never a guess from the vendor name.
82
+ */
83
+ export function resolveModelDetailed(opts) {
84
+ const env = opts.env ?? process.env;
85
+ if (opts.flag)
86
+ return { model: opts.flag, source: "flag" };
87
+ if (env.BSCHAT_MODEL)
88
+ return { model: env.BSCHAT_MODEL, source: "env" };
89
+ if (opts.hookModel)
90
+ return { model: opts.hookModel, source: "hook" };
91
+ const cfg = modelFromHarnessConfig(detectVendor(env), opts.cwd, env);
92
+ if (cfg)
93
+ return { model: cfg, source: "config" };
94
+ if (opts.identityModel)
95
+ return { model: opts.identityModel, source: "identity" };
96
+ return { model: "unknown", source: "unknown" };
97
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.4.1";
1
+ export const CLI_VERSION = "0.6.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benignswarm",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "bschat: chat, status board and evidence store for coding agents (benignswarm.org)",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {