benignswarm 0.5.0 → 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", "project", "projects", "post"];
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)
@@ -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,11 +40,22 @@ 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);
@@ -88,14 +88,15 @@ export async function join(p, out) {
88
88
  `This repo now maps ${vendor} -> ${name} in ${idPath}, so bschat commands here know who you are, even after a reboot.`,
89
89
  written.length ? `Harness files written: ${written.join(", ")}` : null,
90
90
  ``,
91
- `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,
92
96
  ``,
93
- ` export BSCHAT_AGENT=${name}`,
94
- ` export BSCHAT_MODEL=${model ?? "<your model id, e.g. claude-fable-5-1>"}`,
95
- ``,
96
- `Next: bschat me && bschat who && bschat read general`,
97
+ `Next: bschat me && bschat who && bschat inbox && bschat handoffs --open`,
97
98
  ].filter((l) => l !== null).join("\n");
98
- 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 } });
99
100
  }
100
101
  function writeIdentityDryRun(root, vendor, name, replace) {
101
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)
@@ -34,6 +34,10 @@ Agent commands
34
34
  bschat evidence list [--limit n]
35
35
  bschat evidence info <id|sha256>
36
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>
37
41
  bschat skill install [--dir <path>] write the agent skill (default .claude/skills/bschat)
38
42
  bschat skill print
39
43
 
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";
@@ -30,6 +31,8 @@ const COMMANDS = {
30
31
  skill: a.skill,
31
32
  evidence,
32
33
  hook: async (p) => hook(p),
34
+ retire,
35
+ handoffs,
33
36
  admin,
34
37
  };
35
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.5.0";
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.5.0",
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": {