benignswarm 0.5.0 → 0.7.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 +2 -2
- package/dist/commands/agent.js +3 -1
- package/dist/commands/hook.js +32 -3
- package/dist/commands/join.js +7 -6
- package/dist/commands/lists.js +199 -0
- package/dist/commands/retire.js +80 -0
- package/dist/config.js +19 -9
- package/dist/harness.js +1 -1
- package/dist/help.js +12 -1
- package/dist/index.js +7 -0
- package/dist/model.js +97 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
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", "parent", "parent-item", "channel", "statuses", "assign", "if-status", "status"];
|
|
4
|
+
const BOOL_FLAGS = ["json", "follow", "account", "help", "version", "broadcast", "create-channels", "read-account-status", "replace-key", "mentions-me", "no-skill", "replace", "unread", "force", "no-hooks", "yes", "open", "bind-here", "peek"];
|
|
5
5
|
export function parse(argv) {
|
|
6
6
|
const options = {};
|
|
7
7
|
for (const f of STRING_FLAGS)
|
package/dist/commands/agent.js
CHANGED
|
@@ -193,7 +193,9 @@ export async function inbox(p, out) {
|
|
|
193
193
|
if (unread && r.messages.length)
|
|
194
194
|
writeState(creds.agent, { ...state, inbox_after: r.messages[r.messages.length - 1].created_at, last_check: Date.now() });
|
|
195
195
|
const fmt = (m) => `${creds.project ? "" : `[${m.project}] `}${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`;
|
|
196
|
-
|
|
196
|
+
const items = (r.items ?? []).map((i) => `list "${i.list}": [${i.status}] ${i.title} (item ${i.id})`);
|
|
197
|
+
const text = [...r.messages.map(fmt), ...(items.length ? ["assigned to you:", ...items] : [])].join("\n");
|
|
198
|
+
out.print(text || (unread ? "(nothing new for you)" : "(nothing mentions you)"), r);
|
|
197
199
|
}
|
|
198
200
|
export async function channels(p, out) {
|
|
199
201
|
const { client, creds } = agentClient(p);
|
package/dist/commands/hook.js
CHANGED
|
@@ -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,16 +40,29 @@ 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;
|
|
57
|
+
let items = [];
|
|
32
58
|
try {
|
|
33
|
-
const client = Client.forAgent(creds, resolveModel(str(p.flags, "model")));
|
|
59
|
+
const client = Client.forAgent(creds, resolveModel(str(p.flags, "model"), process.env, process.cwd(), name));
|
|
34
60
|
const qs = new URLSearchParams({ limit: "20" });
|
|
35
61
|
if (state.inbox_after)
|
|
36
62
|
qs.set("after", state.inbox_after);
|
|
37
63
|
const r = await client.call("GET", `/inbox?${qs}`);
|
|
38
64
|
messages = r.messages;
|
|
65
|
+
items = r.items ?? [];
|
|
39
66
|
}
|
|
40
67
|
catch (e) {
|
|
41
68
|
process.stderr.write(`bschat hook: ${e.message}\n`);
|
|
@@ -45,12 +72,14 @@ export async function hook(p) {
|
|
|
45
72
|
if (messages.length)
|
|
46
73
|
next.inbox_after = messages[messages.length - 1].created_at;
|
|
47
74
|
writeState(name, next);
|
|
48
|
-
if (!messages.length)
|
|
75
|
+
if (!messages.length && !items.length)
|
|
49
76
|
return;
|
|
50
77
|
const lines = messages.map((m) => `${creds.project ? "" : `[${m.project}] `}${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`);
|
|
78
|
+
const itemLines = items.map((i) => ` list "${i.list}"${i.project && !creds.project ? ` [${i.project}]` : ""}: [${i.status}] ${i.title} (item ${i.id}, by ${i.updated_by})`);
|
|
51
79
|
const text = [
|
|
52
|
-
`[bschat] ${messages.length} new message${messages.length === 1 ? "" : "s"}
|
|
80
|
+
`[bschat] ${messages.length} new message${messages.length === 1 ? "" : "s"}${items.length ? ` and ${items.length} list item${items.length === 1 ? "" : "s"} assigned to you` : ""} (${name}) on BenignSwarm:`,
|
|
53
81
|
...lines,
|
|
82
|
+
...(items.length ? [`Assigned list items (bschat item show <id>; bschat item set <id> --status ...):`, ...itemLines] : []),
|
|
54
83
|
`Reply with: bschat send ${creds.project ? "" : "--project <project> "}<channel> --reply-to <seq> "..." (or just continue if no answer is needed)`,
|
|
55
84
|
].join("\n");
|
|
56
85
|
if (harness === "claude-code") {
|
package/dist/commands/join.js
CHANGED
|
@@ -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
|
-
`
|
|
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
|
-
`
|
|
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,
|
|
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,199 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { bool, str } from "../args.js";
|
|
5
|
+
import { Client } from "../client.js";
|
|
6
|
+
import { loadAgent, resolveAgentName, resolveModel } from "../config.js";
|
|
7
|
+
import { CliError } from "../errors.js";
|
|
8
|
+
import { table } from "../output.js";
|
|
9
|
+
import { projectOf } from "./agent.js";
|
|
10
|
+
function client(p) {
|
|
11
|
+
const name = resolveAgentName(str(p.flags, "as"));
|
|
12
|
+
const creds = loadAgent(name);
|
|
13
|
+
return { client: Client.forAgent(creds, resolveModel(str(p.flags, "model"), process.env, process.cwd(), name)), creds };
|
|
14
|
+
}
|
|
15
|
+
/** Where list commands look: --account, or the project (own project / --project). */
|
|
16
|
+
function scope(p, creds) {
|
|
17
|
+
if (bool(p.flags, "account"))
|
|
18
|
+
return "";
|
|
19
|
+
if (!creds.project && !str(p.flags, "project") && !process.env.BSCHAT_PROJECT)
|
|
20
|
+
return "";
|
|
21
|
+
return `/projects/${projectOf(p, creds)}`;
|
|
22
|
+
}
|
|
23
|
+
function git(args) {
|
|
24
|
+
const r = spawnSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
25
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
26
|
+
}
|
|
27
|
+
/** Resolves <name|id> to a list id within the scope. */
|
|
28
|
+
async function resolveList(c, base, ref) {
|
|
29
|
+
if (/^[0-9a-f-]{36}$/.test(ref))
|
|
30
|
+
return (await c.call("GET", `/lists/${ref}?peek`)).list;
|
|
31
|
+
return (await c.call("GET", `${base}/lists/by-name/${encodeURIComponent(ref)}`)).list;
|
|
32
|
+
}
|
|
33
|
+
function fmtItem(i, statuses) {
|
|
34
|
+
const closed = statuses?.find((s) => s.name === i.status)?.closed;
|
|
35
|
+
return `${i.unread ? "*" : " "} ${closed ? "[x]" : "[ ]"} ${i.id.slice(0, 8)} ${i.status.padEnd(12)} ${i.title}${i.assignee ? ` ${i.assignee}` : ""}${i.evidence?.length ? ` (${i.evidence.length} evidence)` : ""}`;
|
|
36
|
+
}
|
|
37
|
+
export async function lists(p, out) {
|
|
38
|
+
const { client: c, creds } = client(p);
|
|
39
|
+
const r = await c.call("GET", `${scope(p, creds)}/lists`);
|
|
40
|
+
const rows = r.lists.map((l) => ({ name: l.name, items: `${l.open_count} open / ${l.item_count}`, unread: l.unread_items, children: l.child_count, seen: l.my_viewed_at ? "yes" : "no", description: l.description.split("\n")[0].slice(0, 50), id: l.id }));
|
|
41
|
+
out.print(rows.length ? table(rows, ["name", "items", "unread", "children", "seen", "description", "id"]) : "(no lists here; create one with: bschat list create <name>)", r);
|
|
42
|
+
}
|
|
43
|
+
export async function list(p, out) {
|
|
44
|
+
const sub = p.positionals[1];
|
|
45
|
+
const { client: c, creds } = client(p);
|
|
46
|
+
const base = scope(p, creds);
|
|
47
|
+
if (sub === "create" && p.positionals[2]) {
|
|
48
|
+
const body = { name: p.positionals[2], description: str(p.flags, "description") ?? "" };
|
|
49
|
+
const parent = str(p.flags, "parent");
|
|
50
|
+
if (parent) {
|
|
51
|
+
const pl = await resolveList(c, base, parent);
|
|
52
|
+
body.parent_list = pl.id;
|
|
53
|
+
const pi = str(p.flags, "parent-item");
|
|
54
|
+
if (pi)
|
|
55
|
+
body.parent_item = pi;
|
|
56
|
+
}
|
|
57
|
+
const assoc = {};
|
|
58
|
+
const agent = str(p.flags, "agent");
|
|
59
|
+
const channel = str(p.flags, "channel");
|
|
60
|
+
if (agent)
|
|
61
|
+
assoc.agent = agent;
|
|
62
|
+
if (channel)
|
|
63
|
+
assoc.channel = channel;
|
|
64
|
+
if (bool(p.flags, "bind-here")) {
|
|
65
|
+
const repo = git(["remote", "get-url", "origin"]);
|
|
66
|
+
const branch = git(["symbolic-ref", "--short", "-q", "HEAD"]);
|
|
67
|
+
const wt = git(["rev-parse", "--show-toplevel"]);
|
|
68
|
+
if (repo)
|
|
69
|
+
assoc.repo = repo;
|
|
70
|
+
if (branch)
|
|
71
|
+
assoc.branch = branch;
|
|
72
|
+
if (wt)
|
|
73
|
+
assoc.worktree = wt;
|
|
74
|
+
assoc.machine = hostname();
|
|
75
|
+
if (!assoc.agent)
|
|
76
|
+
assoc.agent = creds.agent;
|
|
77
|
+
}
|
|
78
|
+
if (Object.keys(assoc).length)
|
|
79
|
+
body.assoc = assoc;
|
|
80
|
+
const statuses = str(p.flags, "statuses");
|
|
81
|
+
if (statuses)
|
|
82
|
+
body.statuses = statuses.split(",").map((s) => { const [name, flag] = s.trim().split(":"); return { name, closed: flag === "closed" }; });
|
|
83
|
+
const r = await c.call("POST", parent ? "/lists" : `${base}/lists`, body);
|
|
84
|
+
out.print(`created list ${r.list.name} (${r.list.id})${parent ? ` under ${parent}` : ""}`, r);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (sub === "show" && p.positionals[2]) {
|
|
88
|
+
const l = await resolveList(c, base, p.positionals[2]);
|
|
89
|
+
const r = await c.call("GET", `/lists/${l.id}${bool(p.flags, "peek") ? "?peek" : ""}`);
|
|
90
|
+
const L = r.list;
|
|
91
|
+
const lines = [
|
|
92
|
+
`# ${[...L.breadcrumb.map((b) => b.name), L.name].join(" / ")}${L.project ? ` (project ${L.project})` : " (account)"}`,
|
|
93
|
+
L.description ? L.description : null,
|
|
94
|
+
Object.keys(L.assoc).length ? `assoc: ${Object.entries(L.assoc).map(([k, v]) => `${k}=${v}`).join(" ")}` : null,
|
|
95
|
+
`statuses: ${L.statuses.map((s) => s.closed ? `${s.name}(closed)` : s.name).join(", ")}`,
|
|
96
|
+
`viewed by: ${r.viewed_by.map((v) => `${v.viewer_name} ${v.viewed_at.slice(0, 16)}`).join(", ") || "nobody yet"}`,
|
|
97
|
+
``,
|
|
98
|
+
...(r.items.length ? r.items.map((i) => fmtItem(i, L.statuses)) : ["(no items)"]),
|
|
99
|
+
...(r.children.length ? ["", "sub-lists:", ...r.children.map((ch) => ` ${ch.name} (${ch.item_count} items, ${ch.id.slice(0, 8)})`)] : []),
|
|
100
|
+
bool(p.flags, "peek") ? "" : `\n(marked viewed by you)`,
|
|
101
|
+
].filter((x) => x !== null);
|
|
102
|
+
out.print(lines.join("\n"), r);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (sub === "add" && p.positionals[2] && p.positionals[3]) {
|
|
106
|
+
const l = await resolveList(c, base, p.positionals[2]);
|
|
107
|
+
let body = str(p.flags, "body") ?? "";
|
|
108
|
+
const file = str(p.flags, "body-file");
|
|
109
|
+
if (file)
|
|
110
|
+
body = readFileSync(file, "utf8");
|
|
111
|
+
const req = { title: p.positionals.slice(3).join(" "), body };
|
|
112
|
+
const status = str(p.flags, "status");
|
|
113
|
+
if (status)
|
|
114
|
+
req.status = status;
|
|
115
|
+
const assign = str(p.flags, "assign");
|
|
116
|
+
if (assign)
|
|
117
|
+
req.assignee = assign;
|
|
118
|
+
const ev = str(p.flags, "evidence");
|
|
119
|
+
if (ev)
|
|
120
|
+
req.evidence = ev.split(",").map((s) => s.trim()).filter(Boolean);
|
|
121
|
+
const r = await c.call("POST", `/lists/${l.id}/items`, req);
|
|
122
|
+
out.print(`added item ${r.item.id.slice(0, 8)} "${r.item.title}" [${r.item.status}] to ${l.name}${r.item.assignee ? ` for ${r.item.assignee}` : ""}`, r);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (sub === "edit" && p.positionals[2]) {
|
|
126
|
+
const l = await resolveList(c, base, p.positionals[2]);
|
|
127
|
+
const req = {};
|
|
128
|
+
for (const k of ["description", "name"]) {
|
|
129
|
+
const v = str(p.flags, k);
|
|
130
|
+
if (v !== undefined)
|
|
131
|
+
req[k] = v;
|
|
132
|
+
}
|
|
133
|
+
const statuses = str(p.flags, "statuses");
|
|
134
|
+
if (statuses)
|
|
135
|
+
req.statuses = statuses.split(",").map((s) => { const [name, flag] = s.trim().split(":"); return { name, closed: flag === "closed" }; });
|
|
136
|
+
const r = await c.call("PATCH", `/lists/${l.id}`, req);
|
|
137
|
+
out.print(`updated list ${r.list.name}`, r);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (sub === "rm" && p.positionals[2]) {
|
|
141
|
+
const l = await resolveList(c, base, p.positionals[2]);
|
|
142
|
+
if (!bool(p.flags, "yes"))
|
|
143
|
+
throw new CliError("confirm", `this deletes list ${l.name}, its items and sub-lists for everyone; re-run with --yes`);
|
|
144
|
+
await c.call("DELETE", `/lists/${l.id}`);
|
|
145
|
+
out.print(`deleted list ${l.name}`, { ok: true });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
throw new CliError("usage", "list create <name> [--description d] [--parent list] [--parent-item id] [--bind-here] [--agent a] [--channel c] [--statuses open,done:closed] | show <name|id> [--peek] | add <list> <title...> [--body|--body-file] [--status s] [--assign @x] [--evidence ids] | edit <list> [...] | rm <list> --yes");
|
|
149
|
+
}
|
|
150
|
+
export async function item(p, out) {
|
|
151
|
+
const sub = p.positionals[1];
|
|
152
|
+
const id = p.positionals[2];
|
|
153
|
+
const { client: c } = client(p);
|
|
154
|
+
if (!id)
|
|
155
|
+
throw new CliError("usage", "item show|set|done|view|rm <id>");
|
|
156
|
+
const full = /^[0-9a-f-]{36}$/.test(id) ? id : null;
|
|
157
|
+
if (!full)
|
|
158
|
+
throw new CliError("usage", "item commands take the full item id (bschat list show prints the first 8 chars; use --json for full ids)");
|
|
159
|
+
if (sub === "show") {
|
|
160
|
+
const r = await c.call("GET", `/items/${id}`);
|
|
161
|
+
const i = r.item;
|
|
162
|
+
out.print(`${i.title}\nstatus: ${i.status}${i.assignee ? ` assignee: ${i.assignee}` : ""}\nupdated by ${i.updated_by} at ${i.updated_at}\nviewed by: ${r.viewed_by.map((v) => v.viewer_name).join(", ") || "nobody"}${i.evidence.length ? `\nevidence: ${i.evidence.map((e) => `evidence:${e}`).join(" ")}` : ""}${r.sublists.length ? `\nsub-lists: ${r.sublists.map((s) => s.name).join(", ")}` : ""}\n\n${i.body}`, r);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (sub === "set" || sub === "done") {
|
|
166
|
+
const req = {};
|
|
167
|
+
if (sub === "done")
|
|
168
|
+
req.status = "done";
|
|
169
|
+
for (const [flag, key] of [["status", "status"], ["if-status", "if_status"], ["title", "title"], ["body", "body"], ["assign", "assignee"]]) {
|
|
170
|
+
const v = str(p.flags, flag);
|
|
171
|
+
if (v !== undefined)
|
|
172
|
+
req[key] = v;
|
|
173
|
+
}
|
|
174
|
+
const file = str(p.flags, "body-file");
|
|
175
|
+
if (file)
|
|
176
|
+
req.body = readFileSync(file, "utf8");
|
|
177
|
+
const ev = str(p.flags, "evidence");
|
|
178
|
+
if (ev !== undefined)
|
|
179
|
+
req.evidence = ev === "" ? [] : ev.split(",").map((s) => s.trim());
|
|
180
|
+
if (!Object.keys(req).length)
|
|
181
|
+
throw new CliError("usage", "item set needs --status [--if-status] | --title | --body | --body-file | --assign | --evidence");
|
|
182
|
+
const r = await c.call("PATCH", `/items/${id}`, req);
|
|
183
|
+
out.print(`item ${r.item.id.slice(0, 8)} "${r.item.title}" is now [${r.item.status}]${r.item.assignee ? ` for ${r.item.assignee}` : ""}`, r);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (sub === "view") {
|
|
187
|
+
const r = await c.call("POST", `/items/${id}/view`, {});
|
|
188
|
+
out.print(`marked viewed at ${r.viewed_at}`, r);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (sub === "rm") {
|
|
192
|
+
if (!bool(p.flags, "yes"))
|
|
193
|
+
throw new CliError("confirm", "re-run with --yes to delete the item for everyone");
|
|
194
|
+
await c.call("DELETE", `/items/${id}`);
|
|
195
|
+
out.print("deleted", { ok: true });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
throw new CliError("usage", "item show|set|done|view|rm <id>");
|
|
199
|
+
}
|
|
@@ -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
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 (
|
|
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,17 @@ 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>
|
|
41
|
+
bschat lists [--account] shared lists (open/total, your unread count)
|
|
42
|
+
bschat list create <name> [--description d] [--parent <list>] [--parent-item <id>] [--bind-here] [--agent a] [--channel c]
|
|
43
|
+
bschat list show <name|id> [--peek] items, sub-lists, who has viewed; marks viewed unless --peek
|
|
44
|
+
bschat list add <list> <title...> [--body b | --body-file f] [--status s] [--assign @x|@role:y] [--evidence ids]
|
|
45
|
+
bschat list edit <list> [--description d] [--name n] [--statuses open,in_progress,done:closed]
|
|
46
|
+
bschat list rm <list> --yes
|
|
47
|
+
bschat item show|set|done|view|rm <id> item set: --status s [--if-status prev] --title --body --assign --evidence
|
|
37
48
|
bschat skill install [--dir <path>] write the agent skill (default .claude/skills/bschat)
|
|
38
49
|
bschat skill print
|
|
39
50
|
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,8 @@ 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";
|
|
9
|
+
import { item, list, lists } from "./commands/lists.js";
|
|
8
10
|
import { CliError } from "./errors.js";
|
|
9
11
|
import { HELP } from "./help.js";
|
|
10
12
|
import { CLI_VERSION } from "./version.js";
|
|
@@ -30,6 +32,11 @@ const COMMANDS = {
|
|
|
30
32
|
skill: a.skill,
|
|
31
33
|
evidence,
|
|
32
34
|
hook: async (p) => hook(p),
|
|
35
|
+
retire,
|
|
36
|
+
handoffs,
|
|
37
|
+
lists,
|
|
38
|
+
list,
|
|
39
|
+
item,
|
|
33
40
|
admin,
|
|
34
41
|
};
|
|
35
42
|
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.
|
|
1
|
+
export const CLI_VERSION = "0.7.0";
|