@bunshin-cc/bunshin 0.5.2 → 0.6.1
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/bin.js +5157 -4602
- package/dist/claude-code-ui/README.md +8 -1
- package/dist/claude-code-ui/hooks/bunshin-ui.mjs +63 -14
- package/dist/git-remote-bunshin.js +76 -17
- package/package.json +6 -6
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
Bunshin's hooks talk to the model; this mod shows the person what they did. It draws the CLI's
|
|
4
4
|
session feed (`.bunshin/v2/ui/<session>.json`, written by `packages/cli/src/v2/ui.ts`):
|
|
5
5
|
|
|
6
|
-
- **above the prompt**:
|
|
6
|
+
- **above the prompt**: always one row, never wrapped. It shows the document served and why on the left, the
|
|
7
|
+
session's counts on the right, and reviews waiting in yellow:
|
|
8
|
+
|
|
9
|
+
◆ bunshin show-me skill picked by Jev (98%) lib@207 · 3 served · 1 learned
|
|
10
|
+
◇ bunshin maybe agent-grid · manage-agent-fleet skill lib@209 · 7 to review
|
|
11
|
+
|
|
12
|
+
On narrow terminals it drops whole pieces, least useful first: the counts, then the reason. It never drops
|
|
13
|
+
the reviews waiting, and only then trims the name with "…".
|
|
7
14
|
- **`/bunshin`**: a pane with everything served this session and why, the lessons taken from it,
|
|
8
15
|
recent events, and Learn now (`l`), Sync (`s`), Review (`r`)
|
|
9
16
|
- **toasts**: library updates that reached the session, lessons learned, reviews waiting, the
|
|
@@ -224,22 +224,69 @@ function counts(feed) {
|
|
|
224
224
|
].filter(Boolean);
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
/** The
|
|
227
|
+
/** The reason in the band's few columns: the feed's sentence, without its filler. */
|
|
228
|
+
const shortWhy = (why) =>
|
|
229
|
+
String(why ?? "")
|
|
230
|
+
.replace(/^both embedders agree, and Jev$/, "embedders + Jev agree")
|
|
231
|
+
.replace(/^both embedders agree$/, "embedders agree")
|
|
232
|
+
.replace(/, over the embedders$/, "");
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The band above the prompt, always exactly one row (it never wraps into fragments):
|
|
236
|
+
*
|
|
237
|
+
* ◆ bunshin complete-ticket skill embedders agree lib@209 · 1 served · 7 to review
|
|
238
|
+
* ◇ bunshin maybe agent-grid · manage-agent-fleet skill lib@209 · 7 to review
|
|
239
|
+
*
|
|
240
|
+
* What does not fit is dropped whole, least useful first: the quiet counts, then the reason (never
|
|
241
|
+
* the reviews waiting, in yellow); only then does the name end in "…".
|
|
242
|
+
*/
|
|
228
243
|
function band(Box, Text, feed, columns) {
|
|
229
244
|
const latest = feed.served.find((s) => s.how !== "hint");
|
|
230
245
|
const hint = feed.served[0]?.how === "hint" ? feed.served.filter((s) => s.how === "hint" && s.at === feed.served[0].at) : [];
|
|
231
|
-
const
|
|
232
|
-
const
|
|
246
|
+
const served = feed.served.filter((s) => s.how !== "hint").length;
|
|
247
|
+
const quiet = [feed.library ? `lib@${feed.library}` : "", served ? `${served} served` : "", feed.lessons.length ? `${feed.lessons.length} learned` : ""].filter(Boolean).join(" · ");
|
|
248
|
+
const review = feed.review ? `${feed.review} to review` : "";
|
|
249
|
+
const name = latest ? label(latest) : hint.length ? `maybe ${hint.map(label).join(" · ")}` : "listening";
|
|
250
|
+
const why = latest?.why ? ` ${shortWhy(latest.why)}` : "";
|
|
251
|
+
// The cells each piece takes: "◆ bunshin " (11), the name, the reason; the counts plus a 2-cell gap.
|
|
252
|
+
const room = columns - 2;
|
|
253
|
+
const fits = (withWhy, withQuiet) => {
|
|
254
|
+
const right = [withQuiet ? quiet : "", review].filter(Boolean).join(" · ");
|
|
255
|
+
return 11 + name.length + (withWhy ? why.length : 0) + (right ? right.length + 2 : 0) <= room;
|
|
256
|
+
};
|
|
257
|
+
// Keep, in order: the name, the reviews waiting, the reason, the quiet counts.
|
|
258
|
+
const showWhy = !!why && fits(true, false);
|
|
259
|
+
const showQuiet = !!quiet && fits(showWhy, true);
|
|
260
|
+
|
|
261
|
+
const left = [];
|
|
233
262
|
if (latest) {
|
|
234
|
-
|
|
235
|
-
|
|
263
|
+
left.push(Text({ color: BRAND, bold: true, children: "◆ bunshin " }));
|
|
264
|
+
left.push(Text({ bold: true, children: label(latest) }));
|
|
265
|
+
if (showWhy) left.push(Text({ dimColor: true, children: why }));
|
|
236
266
|
} else if (hint.length) {
|
|
237
|
-
|
|
267
|
+
left.push(Text({ color: BRAND, children: "◇ bunshin " }));
|
|
268
|
+
left.push(Text({ dimColor: true, italic: true, children: "maybe " }));
|
|
269
|
+
left.push(Text({ dimColor: true, children: hint.map(label).join(" · ") }));
|
|
238
270
|
} else {
|
|
239
|
-
|
|
271
|
+
left.push(Text({ color: BRAND, children: "◇ bunshin " }));
|
|
272
|
+
left.push(Text({ dimColor: true, italic: true, children: "listening" }));
|
|
273
|
+
}
|
|
274
|
+
const right = [];
|
|
275
|
+
if (showQuiet) right.push(Text({ dimColor: true, children: quiet }));
|
|
276
|
+
if (review) {
|
|
277
|
+
if (right.length) right.push(Text({ dimColor: true, children: " · " }));
|
|
278
|
+
right.push(Text({ color: "yellow", children: review }));
|
|
240
279
|
}
|
|
241
|
-
|
|
242
|
-
|
|
280
|
+
return Box({
|
|
281
|
+
flexDirection: "row",
|
|
282
|
+
justifyContent: "space-between",
|
|
283
|
+
width: columns,
|
|
284
|
+
paddingX: 1,
|
|
285
|
+
children: [
|
|
286
|
+
Box({ flexShrink: 1, flexGrow: 1, overflow: "hidden", children: [Text({ wrap: "truncate-end", children: left })] }),
|
|
287
|
+
...(right.length ? [Box({ flexShrink: 0, marginLeft: 2, children: [Text({ wrap: "truncate-end", children: right })] })] : []),
|
|
288
|
+
],
|
|
289
|
+
});
|
|
243
290
|
}
|
|
244
291
|
|
|
245
292
|
function plainSummary(feed) {
|
|
@@ -268,14 +315,16 @@ function pane({ Box, Text, Button }, feed, panel, columns, rows) {
|
|
|
268
315
|
body.push(heading(Text, "Served this session"));
|
|
269
316
|
const served = feed.served.slice(0, Math.max(3, Math.floor(rows / 3)));
|
|
270
317
|
if (!served.length) body.push(Text({ dimColor: true, children: " nothing yet" }));
|
|
318
|
+
// One line per row, never wrapped into fragments: time and how, the document, why.
|
|
271
319
|
for (const s of served) {
|
|
272
320
|
body.push(
|
|
273
|
-
|
|
274
|
-
|
|
321
|
+
Text({
|
|
322
|
+
wrap: "truncate-end",
|
|
275
323
|
children: [
|
|
276
|
-
Text({ dimColor: true, children: ` ${hhmm(s.at)}
|
|
277
|
-
Text({
|
|
278
|
-
Text({
|
|
324
|
+
Text({ dimColor: true, children: ` ${hhmm(s.at)} ` }),
|
|
325
|
+
Text({ color: s.how === "hint" ? undefined : BRAND, dimColor: s.how === "hint", children: `${s.how === "hint" ? "◇" : "◆"} ` }),
|
|
326
|
+
Text({ bold: s.how !== "hint", dimColor: s.how === "hint", children: label(s) }),
|
|
327
|
+
Text({ dimColor: true, children: s.why ? ` ${shortWhy(s.why)}` : s.how === "hint" ? " maybe" : "" }),
|
|
279
328
|
],
|
|
280
329
|
}),
|
|
281
330
|
);
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/v2/gitremote.ts
|
|
4
|
-
import
|
|
4
|
+
import fs3 from "node:fs";
|
|
5
5
|
import os from "node:os";
|
|
6
|
-
import
|
|
6
|
+
import path2 from "node:path";
|
|
7
7
|
import readline from "node:readline";
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
|
|
10
|
+
// src/trust.ts
|
|
11
|
+
import fs2 from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
10
14
|
// src/util.ts
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
function readJson(file, fallback) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
19
|
+
} catch {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
11
23
|
var STOPWORDS = new Set(
|
|
12
24
|
"a an the and or but if then else of to in on at for from by with without as is are was were be been being do does did done have has had can could should would will shall may might must this that these those it its i we you they he she them us our your my me not no yes so just also very really please now new use using make get set add fix run change update create write need want like about into over under out up down how what when where why which who whom there here some any all each every more most less least than too own same other".split(" ")
|
|
13
25
|
);
|
|
@@ -15,6 +27,50 @@ function homeDir() {
|
|
|
15
27
|
return process.env.HOME || process.env.USERPROFILE || "/";
|
|
16
28
|
}
|
|
17
29
|
|
|
30
|
+
// src/trust.ts
|
|
31
|
+
var HOSTED_API = "https://api.bunshin.cc";
|
|
32
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
33
|
+
var configDir = () => path.join(homeDir(), ".config/bunshin");
|
|
34
|
+
var trustFile = () => path.join(configDir(), "trusted-stores.json");
|
|
35
|
+
var credentialDir = () => path.join(configDir(), "credentials");
|
|
36
|
+
function secureUrl(raw) {
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(raw ?? "");
|
|
39
|
+
return url.protocol === "https:" || url.protocol === "http:" && LOOPBACK.has(url.hostname);
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function urlOrigin(raw) {
|
|
45
|
+
try {
|
|
46
|
+
return new URL(raw ?? "").origin;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function trustedStores() {
|
|
52
|
+
const list = readJson(trustFile(), {}).stores;
|
|
53
|
+
return Array.isArray(list) ? list.filter((x) => typeof x === "string") : [];
|
|
54
|
+
}
|
|
55
|
+
function trustedOrigins() {
|
|
56
|
+
const out = /* @__PURE__ */ new Set([HOSTED_API]);
|
|
57
|
+
for (const root of trustedStores()) {
|
|
58
|
+
const url = readJson(path.join(root, "config.json"), {}).apiUrl;
|
|
59
|
+
if (url) out.add(url);
|
|
60
|
+
}
|
|
61
|
+
for (const url of Object.keys(readJson(path.join(configDir(), "git-credentials.json"), {}))) out.add(url);
|
|
62
|
+
let files = [];
|
|
63
|
+
try {
|
|
64
|
+
files = fs2.readdirSync(credentialDir()).filter((f) => f.endsWith(".json"));
|
|
65
|
+
} catch {
|
|
66
|
+
}
|
|
67
|
+
for (const f of files) {
|
|
68
|
+
const url = readJson(path.join(credentialDir(), f), {}).apiUrl;
|
|
69
|
+
if (url) out.add(url);
|
|
70
|
+
}
|
|
71
|
+
return new Set([...out].map(urlOrigin).filter((x) => !!x));
|
|
72
|
+
}
|
|
73
|
+
|
|
18
74
|
// src/v2/gitremote.ts
|
|
19
75
|
var ZERO_SHA = "0".repeat(40);
|
|
20
76
|
var REMOTE_PATH = "/v1/git/repos/";
|
|
@@ -25,27 +81,30 @@ function parseRemoteUrl(url) {
|
|
|
25
81
|
const apiUrl = raw.slice(0, i);
|
|
26
82
|
const repo = decodeURIComponent(raw.slice(i + REMOTE_PATH.length));
|
|
27
83
|
if (!/^https?:\/\//.test(apiUrl) || repo.split("/").length !== 2) throw new Error(`not a Bunshin repository address: ${url}`);
|
|
84
|
+
if (!secureUrl(apiUrl)) throw new Error(`refusing ${apiUrl}: Bunshin repositories need https (http only on localhost)`);
|
|
28
85
|
return { apiUrl, repo };
|
|
29
86
|
}
|
|
30
|
-
var gitCredentialFile = () =>
|
|
87
|
+
var gitCredentialFile = () => path2.join(homeDir(), ".config/bunshin/git-credentials.json");
|
|
31
88
|
function resolveGitKey(apiUrl) {
|
|
32
89
|
const base = apiUrl.replace(/\/$/, "");
|
|
33
|
-
if (
|
|
90
|
+
if (!secureUrl(base)) return void 0;
|
|
91
|
+
const origin = urlOrigin(base);
|
|
92
|
+
if (process.env.BUNSHIN_API_KEY && origin && trustedOrigins().has(origin)) return process.env.BUNSHIN_API_KEY;
|
|
34
93
|
try {
|
|
35
|
-
const all = JSON.parse(
|
|
94
|
+
const all = JSON.parse(fs3.readFileSync(gitCredentialFile(), "utf8"));
|
|
36
95
|
if (all[base]) return all[base];
|
|
37
96
|
} catch {
|
|
38
97
|
}
|
|
39
|
-
const dir =
|
|
98
|
+
const dir = path2.join(homeDir(), ".config/bunshin/credentials");
|
|
40
99
|
let files = [];
|
|
41
100
|
try {
|
|
42
|
-
files =
|
|
101
|
+
files = fs3.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
43
102
|
} catch {
|
|
44
103
|
return void 0;
|
|
45
104
|
}
|
|
46
105
|
for (const f of files.sort()) {
|
|
47
106
|
try {
|
|
48
|
-
const c = JSON.parse(
|
|
107
|
+
const c = JSON.parse(fs3.readFileSync(path2.join(dir, f), "utf8"));
|
|
49
108
|
if (c.apiKey && c.apiUrl?.replace(/\/$/, "") === base) return c.apiKey;
|
|
50
109
|
} catch {
|
|
51
110
|
}
|
|
@@ -171,7 +230,7 @@ async function fetchObjects(client, repo, wanted, log = () => {
|
|
|
171
230
|
const have = presentObjects([...new Set(meta.flatMap((b) => b.tips))]);
|
|
172
231
|
const needed = meta.filter((b) => b.tips.some((t) => !have.has(t)));
|
|
173
232
|
if (!needed.length) throw new Error("the server has no bundle with the missing objects");
|
|
174
|
-
const tmp =
|
|
233
|
+
const tmp = fs3.mkdtempSync(path2.join(os.tmpdir(), "bunshin-git-"));
|
|
175
234
|
try {
|
|
176
235
|
let after = needed[0].seq - 1;
|
|
177
236
|
const want = new Set(needed.map((b) => b.seq));
|
|
@@ -181,10 +240,10 @@ async function fetchObjects(client, repo, wanted, log = () => {
|
|
|
181
240
|
for (const b of page.bundles) {
|
|
182
241
|
after = b.seq;
|
|
183
242
|
if (!want.has(b.seq) || !b.data) continue;
|
|
184
|
-
const file =
|
|
185
|
-
|
|
243
|
+
const file = path2.join(tmp, `${b.seq}.bundle`);
|
|
244
|
+
fs3.writeFileSync(file, Buffer.from(b.data, "base64"));
|
|
186
245
|
git(["bundle", "unbundle", file]);
|
|
187
|
-
|
|
246
|
+
fs3.rmSync(file, { force: true });
|
|
188
247
|
applied++;
|
|
189
248
|
}
|
|
190
249
|
if (!page.more || !page.bundles.length) break;
|
|
@@ -192,7 +251,7 @@ async function fetchObjects(client, repo, wanted, log = () => {
|
|
|
192
251
|
log(`bunshin: applied ${applied} bundle${applied === 1 ? "" : "s"}
|
|
193
252
|
`);
|
|
194
253
|
} finally {
|
|
195
|
-
|
|
254
|
+
fs3.rmSync(tmp, { recursive: true, force: true });
|
|
196
255
|
}
|
|
197
256
|
const still = wanted.filter((s) => !presentObjects([s]).has(s));
|
|
198
257
|
if (still.length) throw new Error(`objects still missing after fetch: ${still.slice(0, 3).join(", ")}`);
|
|
@@ -246,7 +305,7 @@ async function pushRefs(client, repo, specs) {
|
|
|
246
305
|
const sends = updates.filter((u) => u.new !== ZERO_SHA);
|
|
247
306
|
let bundle = null;
|
|
248
307
|
if (sends.length) {
|
|
249
|
-
const tmp =
|
|
308
|
+
const tmp = fs3.mkdtempSync(path2.join(os.tmpdir(), "bunshin-git-"));
|
|
250
309
|
const tmpRefs = [];
|
|
251
310
|
try {
|
|
252
311
|
sends.forEach((u, i) => {
|
|
@@ -255,9 +314,9 @@ async function pushRefs(client, repo, specs) {
|
|
|
255
314
|
tmpRefs.push(ref);
|
|
256
315
|
});
|
|
257
316
|
const known = presentObjects([...new Set(remote.values())]);
|
|
258
|
-
const file =
|
|
317
|
+
const file = path2.join(tmp, "push.bundle");
|
|
259
318
|
const r = git(["bundle", "create", "--quiet", file, ...tmpRefs, ...[...known].map((k) => `^${k}`)], { allowFail: true });
|
|
260
|
-
if (r.ok) bundle = new Uint8Array(
|
|
319
|
+
if (r.ok) bundle = new Uint8Array(fs3.readFileSync(file));
|
|
261
320
|
else if (!/empty bundle/i.test(r.err)) throw new Error(r.err.trim() || "git bundle create failed");
|
|
262
321
|
else {
|
|
263
322
|
for (const u of sends) {
|
|
@@ -267,7 +326,7 @@ async function pushRefs(client, repo, specs) {
|
|
|
267
326
|
}
|
|
268
327
|
} finally {
|
|
269
328
|
for (const ref of tmpRefs) git(["update-ref", "-d", ref], { allowFail: true });
|
|
270
|
-
|
|
329
|
+
fs3.rmSync(tmp, { recursive: true, force: true });
|
|
271
330
|
}
|
|
272
331
|
}
|
|
273
332
|
if (updates.length) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bunshin-cc/bunshin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Shared memory for company AI agents. Connect Codex, Claude Code, or Pi to Bunshin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,15 +16,15 @@
|
|
|
16
16
|
"node": ">=20"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@anthropic-ai/sdk": "^0.
|
|
19
|
+
"@anthropic-ai/sdk": "^0.129.0",
|
|
20
20
|
"yaml": "^2.8.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@types/node": "^
|
|
23
|
+
"@types/node": "^26.6.3",
|
|
24
24
|
"esbuild": "^0.28.1",
|
|
25
|
-
"tsx": "^4.
|
|
26
|
-
"typescript": "^
|
|
27
|
-
"vitest": "^
|
|
25
|
+
"tsx": "^4.23.15",
|
|
26
|
+
"typescript": "^7.0.2",
|
|
27
|
+
"vitest": "^5.0.2",
|
|
28
28
|
"@bunshin/core": "0.1.0"
|
|
29
29
|
},
|
|
30
30
|
"license": "MIT",
|