beecork 2.8.0 → 2.8.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/index.js +121 -44
- package/package.json +3 -2
- package/skeleton/bridge.mjs +176 -0
- package/skills/browser-signals.md +8 -6
package/dist/index.js
CHANGED
|
@@ -1195,11 +1195,11 @@ function createMarkdownStream(write) {
|
|
|
1195
1195
|
}
|
|
1196
1196
|
|
|
1197
1197
|
// src/tools.ts
|
|
1198
|
-
import { readFile as readFile3, writeFile as writeFile2, appendFile, readdir as readdir2, mkdir as
|
|
1198
|
+
import { readFile as readFile3, writeFile as writeFile2, appendFile, readdir as readdir2, mkdir as mkdir3, stat, rename, chmod } from "node:fs/promises";
|
|
1199
1199
|
import { createReadStream } from "node:fs";
|
|
1200
1200
|
import { createInterface as createLineReader } from "node:readline";
|
|
1201
|
-
import { spawn as
|
|
1202
|
-
import { join as
|
|
1201
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
1202
|
+
import { join as join4 } from "node:path";
|
|
1203
1203
|
import { lookup as dnsLookup } from "node:dns";
|
|
1204
1204
|
import { request as httpRequest } from "node:http";
|
|
1205
1205
|
import { request as httpsRequest } from "node:https";
|
|
@@ -1678,11 +1678,80 @@ function decodeEntities(s) {
|
|
|
1678
1678
|
});
|
|
1679
1679
|
}
|
|
1680
1680
|
|
|
1681
|
+
// src/skeleton.ts
|
|
1682
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
1683
|
+
import { mkdir as mkdir2 } from "node:fs/promises";
|
|
1684
|
+
import { join as join3, dirname as dirname3 } from "node:path";
|
|
1685
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
1686
|
+
import { homedir as homedir5 } from "node:os";
|
|
1687
|
+
var port = () => Number(process.env.BEECORK_SKELETON_PORT) || 8317;
|
|
1688
|
+
var skeletonUrl = () => process.env.BEECORK_DEV_SIGNALS_URL || `http://localhost:${port()}`;
|
|
1689
|
+
var managedExternally = () => !!process.env.BEECORK_DEV_SIGNALS_URL;
|
|
1690
|
+
var skeletonHome = () => process.env.BEECORK_SKELETON_HOME || join3(homedir5(), ".beecork", "skeleton");
|
|
1691
|
+
var bridgeScript = () => join3(dirname3(fileURLToPath3(import.meta.url)), "..", "skeleton", "bridge.mjs");
|
|
1692
|
+
async function probe(url = skeletonUrl(), timeoutMs = 600) {
|
|
1693
|
+
try {
|
|
1694
|
+
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
1695
|
+
if (res.ok) {
|
|
1696
|
+
const j = await res.json();
|
|
1697
|
+
if (j && j.skeleton === true) return "up";
|
|
1698
|
+
}
|
|
1699
|
+
} catch {
|
|
1700
|
+
return "down";
|
|
1701
|
+
}
|
|
1702
|
+
try {
|
|
1703
|
+
const res = await fetch(`${url}/signals?limit=1`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
1704
|
+
if (res.ok) {
|
|
1705
|
+
const j = await res.json();
|
|
1706
|
+
if (Array.isArray(j && j.signals)) return "up";
|
|
1707
|
+
}
|
|
1708
|
+
} catch {
|
|
1709
|
+
}
|
|
1710
|
+
return "foreign";
|
|
1711
|
+
}
|
|
1712
|
+
var inFlight = null;
|
|
1713
|
+
function ensureBridge() {
|
|
1714
|
+
if (inFlight) return inFlight;
|
|
1715
|
+
inFlight = doEnsure().finally(() => {
|
|
1716
|
+
inFlight = null;
|
|
1717
|
+
});
|
|
1718
|
+
return inFlight;
|
|
1719
|
+
}
|
|
1720
|
+
async function doEnsure() {
|
|
1721
|
+
if (managedExternally()) return { up: false, reason: "external" };
|
|
1722
|
+
const first = await probe();
|
|
1723
|
+
if (first === "up") return { up: true };
|
|
1724
|
+
if (first === "foreign") return { up: false, reason: "foreign-port" };
|
|
1725
|
+
let pid;
|
|
1726
|
+
try {
|
|
1727
|
+
const home = skeletonHome();
|
|
1728
|
+
await mkdir2(home, { recursive: true });
|
|
1729
|
+
const child = spawn3(process.execPath, [bridgeScript()], {
|
|
1730
|
+
cwd: home,
|
|
1731
|
+
env: { ...process.env, BEECORK_SKELETON_HOME: home, BEECORK_SKELETON_PORT: String(port()) },
|
|
1732
|
+
detached: true,
|
|
1733
|
+
stdio: "ignore"
|
|
1734
|
+
// fire-and-forget; it logs to no one, which is fine
|
|
1735
|
+
});
|
|
1736
|
+
child.on("error", () => {
|
|
1737
|
+
});
|
|
1738
|
+
pid = child.pid;
|
|
1739
|
+
child.unref();
|
|
1740
|
+
} catch {
|
|
1741
|
+
return { up: false, reason: "spawn-failed" };
|
|
1742
|
+
}
|
|
1743
|
+
for (let i = 0; i < 10; i++) {
|
|
1744
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1745
|
+
if (await probe() === "up") return { up: true, started: true, pid };
|
|
1746
|
+
}
|
|
1747
|
+
return { up: false, started: true, pid, reason: "spawn-failed" };
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1681
1750
|
// src/tools.ts
|
|
1682
1751
|
function runShell(command, opts) {
|
|
1683
1752
|
return new Promise((resolve2, reject) => {
|
|
1684
1753
|
const unix2 = process.platform !== "win32";
|
|
1685
|
-
const child =
|
|
1754
|
+
const child = spawn4(command, { shell: true, detached: unix2, stdio: ["ignore", "pipe", "pipe"] });
|
|
1686
1755
|
let stdout = "", stderr = "", outLen = 0, errLen = 0, timedOut = false, aborted = false;
|
|
1687
1756
|
let settled = false, exitCode = null;
|
|
1688
1757
|
const kill = () => {
|
|
@@ -1964,7 +2033,7 @@ async function walkTree(abs, prefix, items, cap) {
|
|
|
1964
2033
|
const e = kept[i];
|
|
1965
2034
|
const last = i === kept.length - 1;
|
|
1966
2035
|
items.push({ prefix: prefix + (last ? "\u2514\u2500 " : "\u251C\u2500 "), name: e.name + (e.isDirectory() ? "/" : ""), isDir: e.isDirectory() });
|
|
1967
|
-
if (e.isDirectory()) await walkTree(
|
|
2036
|
+
if (e.isDirectory()) await walkTree(join4(abs, e.name), prefix + (last ? " " : "\u2502 "), items, cap);
|
|
1968
2037
|
}
|
|
1969
2038
|
}
|
|
1970
2039
|
function parseRange(args, defLimit) {
|
|
@@ -1994,14 +2063,13 @@ async function readLineWindow(abs, offset1, limit) {
|
|
|
1994
2063
|
}
|
|
1995
2064
|
return { lines, startLine: start + 1, hasMore, empty: i === 0 };
|
|
1996
2065
|
}
|
|
1997
|
-
var
|
|
2066
|
+
var EXTENSION_STEPS = `1. Load the extension: Chrome \u2192 chrome://extensions \u2192 turn on "Developer mode" \u2192 "Load unpacked" \u2192 select the beecork-extension/extension folder, and pin the icon.
|
|
2067
|
+
2. Click the icon (it auto-connects \u2014 no token to paste), tick "Capture enabled", open the app in a tab, and click "Pair this site".`;
|
|
2068
|
+
var DEV_SIGNALS_SETUP = `The browser link isn't connected yet.
|
|
1998
2069
|
|
|
1999
|
-
This is "Beecork Skeleton" \u2014 a Chrome extension that
|
|
2070
|
+
This is "Beecork Skeleton" \u2014 a Chrome extension that streams the app's console errors and failed network requests to me, so I see what the browser sees instead of guessing. beecork runs the local inbox for you automatically; the only one-time step is loading the extension (local-only, no account):
|
|
2000
2071
|
|
|
2001
|
-
|
|
2002
|
-
1. Start the local inbox: run \`node bridge/server.mjs\` in the beecork-extension folder, and leave it running.
|
|
2003
|
-
2. Load the extension: Chrome \u2192 chrome://extensions \u2192 turn on "Developer mode" \u2192 "Load unpacked" \u2192 select the beecork-extension/extension folder. Pin the icon.
|
|
2004
|
-
3. Click the icon (it auto-connects), tick "Capture enabled", open the app in a tab, and click "Pair this site".
|
|
2072
|
+
` + EXTENSION_STEPS + `
|
|
2005
2073
|
|
|
2006
2074
|
Then call read_dev_signals again. Full step-by-step + troubleshooting is in the "browser-signals" skill.`;
|
|
2007
2075
|
var toolDefs = [
|
|
@@ -2397,9 +2465,9 @@ ${list}`;
|
|
|
2397
2465
|
},
|
|
2398
2466
|
run: async (args) => {
|
|
2399
2467
|
try {
|
|
2400
|
-
const dir =
|
|
2401
|
-
await
|
|
2402
|
-
const file =
|
|
2468
|
+
const dir = join4(process.cwd(), ".beecork");
|
|
2469
|
+
await mkdir3(dir, { recursive: true });
|
|
2470
|
+
const file = join4(dir, "memory.md");
|
|
2403
2471
|
const fact = String(args.fact).trim();
|
|
2404
2472
|
if (!fact) return 'Error: remember needs a non-empty "fact".';
|
|
2405
2473
|
let current = "";
|
|
@@ -2523,7 +2591,8 @@ ${skill.content}`;
|
|
|
2523
2591
|
required: []
|
|
2524
2592
|
},
|
|
2525
2593
|
run: async (args, signal) => {
|
|
2526
|
-
const
|
|
2594
|
+
const ens = await ensureBridge().catch(() => ({ up: false, reason: "spawn-failed" }));
|
|
2595
|
+
const base = skeletonUrl();
|
|
2527
2596
|
const kind = args.kind ? String(args.kind) : "";
|
|
2528
2597
|
const limit = Math.min(Math.max(Number(args.limit) || 30, 1), 200);
|
|
2529
2598
|
const sinceMin = Number(args.since_minutes) || 0;
|
|
@@ -2534,15 +2603,21 @@ ${skill.content}`;
|
|
|
2534
2603
|
let data;
|
|
2535
2604
|
try {
|
|
2536
2605
|
const res = await fetch(`${base}/signals?${params}`, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
|
|
2537
|
-
if (!res.ok) return `The browser link responded with HTTP ${res.status}. The
|
|
2606
|
+
if (!res.ok) return `The browser link responded with HTTP ${res.status}. The inbox may be unhealthy \u2014 I'll try to start a fresh one on the next call.`;
|
|
2538
2607
|
data = await res.json();
|
|
2539
2608
|
} catch {
|
|
2609
|
+
if (ens.reason === "foreign-port") return `Another program is using the browser-link inbox port, so I couldn't start it. Free that port (or set BEECORK_DEV_SIGNALS_URL to a different inbox) and try again.`;
|
|
2540
2610
|
return DEV_SIGNALS_SETUP;
|
|
2541
2611
|
}
|
|
2542
2612
|
const now = Date.now();
|
|
2543
2613
|
const signals = (data.signals ?? []).filter((s) => s && s.kind !== "watch");
|
|
2544
2614
|
if (signals.length === 0) {
|
|
2545
|
-
return `The
|
|
2615
|
+
return `The inbox is running${ens.started ? " (I just started it)" : ""}, but no ${kind && kind !== "all" ? `"${kind}" ` : ""}signals were captured${sinceMin ? ` in the last ${sinceMin} min` : ""} yet.
|
|
2616
|
+
|
|
2617
|
+
If the Beecork Skeleton extension isn't loaded yet, connect it:
|
|
2618
|
+
${EXTENSION_STEPS}
|
|
2619
|
+
|
|
2620
|
+
Already connected? Reproduce the issue in the browser (or open the app), then call read_dev_signals again.`;
|
|
2546
2621
|
}
|
|
2547
2622
|
const ago2 = (ts) => ts ? `${Math.max(0, Math.round((now - ts) / 1e3))}s ago` : "";
|
|
2548
2623
|
const lines = signals.map((s) => {
|
|
@@ -2566,7 +2641,9 @@ ${lines.join("\n")}`;
|
|
|
2566
2641
|
required: ["url"]
|
|
2567
2642
|
},
|
|
2568
2643
|
run: async (args, signal) => {
|
|
2569
|
-
|
|
2644
|
+
await ensureBridge().catch(() => {
|
|
2645
|
+
});
|
|
2646
|
+
const base = skeletonUrl();
|
|
2570
2647
|
let origin;
|
|
2571
2648
|
try {
|
|
2572
2649
|
origin = new URL(String(args.url ?? "")).origin;
|
|
@@ -2951,32 +3028,32 @@ ${summary}` }, ...recent];
|
|
|
2951
3028
|
}
|
|
2952
3029
|
|
|
2953
3030
|
// src/memory.ts
|
|
2954
|
-
import { readFile as readFile4, writeFile as writeFile3, readdir as readdir3, mkdir as
|
|
2955
|
-
import { homedir as
|
|
2956
|
-
import { join as
|
|
3031
|
+
import { readFile as readFile4, writeFile as writeFile3, readdir as readdir3, mkdir as mkdir4, chmod as chmod2, rename as rename2, unlink } from "node:fs/promises";
|
|
3032
|
+
import { homedir as homedir6 } from "node:os";
|
|
3033
|
+
import { join as join5, dirname as dirname4 } from "node:path";
|
|
2957
3034
|
var BEECORK = ".beecork";
|
|
2958
3035
|
function ancestorDirs() {
|
|
2959
|
-
const home =
|
|
3036
|
+
const home = homedir6();
|
|
2960
3037
|
const dirs = [];
|
|
2961
3038
|
let dir = process.cwd();
|
|
2962
|
-
while (dir !== home && dir !==
|
|
3039
|
+
while (dir !== home && dir !== dirname4(dir)) {
|
|
2963
3040
|
dirs.push(dir);
|
|
2964
|
-
dir =
|
|
3041
|
+
dir = dirname4(dir);
|
|
2965
3042
|
}
|
|
2966
3043
|
return dirs.reverse();
|
|
2967
3044
|
}
|
|
2968
3045
|
function corkPaths() {
|
|
2969
|
-
return [
|
|
3046
|
+
return [join5(homedir6(), BEECORK, "cork.md"), ...ancestorDirs().map((d) => join5(d, "cork.md"))];
|
|
2970
3047
|
}
|
|
2971
3048
|
function beecorkPaths(name) {
|
|
2972
|
-
return [
|
|
3049
|
+
return [join5(homedir6(), BEECORK, name), ...ancestorDirs().map((d) => join5(d, BEECORK, name))];
|
|
2973
3050
|
}
|
|
2974
3051
|
function standardInstructionPaths() {
|
|
2975
|
-
return ancestorDirs().flatMap((d) => [
|
|
3052
|
+
return ancestorDirs().flatMap((d) => [join5(d, "AGENTS.md"), join5(d, "CLAUDE.md")]);
|
|
2976
3053
|
}
|
|
2977
3054
|
async function loadInstructions() {
|
|
2978
|
-
const home =
|
|
2979
|
-
const homeBeecork =
|
|
3055
|
+
const home = homedir6();
|
|
3056
|
+
const homeBeecork = join5(home, ".beecork");
|
|
2980
3057
|
const trusted = [];
|
|
2981
3058
|
const project = [];
|
|
2982
3059
|
const sources = [];
|
|
@@ -3029,14 +3106,14 @@ async function loadSettings() {
|
|
|
3029
3106
|
return { model, reasoningEffort, alwaysAllow, projectAlwaysAllowIgnored };
|
|
3030
3107
|
}
|
|
3031
3108
|
function userConfigPath() {
|
|
3032
|
-
return
|
|
3109
|
+
return join5(homedir6(), BEECORK, "config.json");
|
|
3033
3110
|
}
|
|
3034
3111
|
async function loadUserConfig() {
|
|
3035
3112
|
return await readJsonFile(userConfigPath()) ?? {};
|
|
3036
3113
|
}
|
|
3037
3114
|
async function saveUserConfig(patch) {
|
|
3038
3115
|
const file = userConfigPath();
|
|
3039
|
-
await
|
|
3116
|
+
await mkdir4(dirname4(file), { recursive: true });
|
|
3040
3117
|
const merged = { ...await loadUserConfig(), ...patch };
|
|
3041
3118
|
const tmp = `${file}.tmp`;
|
|
3042
3119
|
await writeFile3(tmp, JSON.stringify(merged, null, 2), { encoding: "utf8", mode: 384 });
|
|
@@ -3046,8 +3123,8 @@ async function saveUserConfig(patch) {
|
|
|
3046
3123
|
}
|
|
3047
3124
|
async function saveModelPreference(model) {
|
|
3048
3125
|
try {
|
|
3049
|
-
const file =
|
|
3050
|
-
await
|
|
3126
|
+
const file = join5(homedir6(), BEECORK, "settings.json");
|
|
3127
|
+
await mkdir4(dirname4(file), { recursive: true });
|
|
3051
3128
|
const current = await readJsonFile(file) ?? {};
|
|
3052
3129
|
await writeFile3(file, JSON.stringify({ ...current, model }, null, 2), "utf8");
|
|
3053
3130
|
} catch {
|
|
@@ -3055,19 +3132,19 @@ async function saveModelPreference(model) {
|
|
|
3055
3132
|
}
|
|
3056
3133
|
async function saveReasoningPreference(reasoningEffort) {
|
|
3057
3134
|
try {
|
|
3058
|
-
const file =
|
|
3059
|
-
await
|
|
3135
|
+
const file = join5(homedir6(), BEECORK, "settings.json");
|
|
3136
|
+
await mkdir4(dirname4(file), { recursive: true });
|
|
3060
3137
|
const current = await readJsonFile(file) ?? {};
|
|
3061
3138
|
await writeFile3(file, JSON.stringify({ ...current, reasoningEffort }, null, 2), "utf8");
|
|
3062
3139
|
} catch {
|
|
3063
3140
|
}
|
|
3064
3141
|
}
|
|
3065
|
-
var sessionsDir = () =>
|
|
3142
|
+
var sessionsDir = () => join5(process.cwd(), BEECORK, "sessions");
|
|
3066
3143
|
async function saveSession(messages) {
|
|
3067
3144
|
try {
|
|
3068
3145
|
const dir = sessionsDir();
|
|
3069
|
-
await
|
|
3070
|
-
const file =
|
|
3146
|
+
await mkdir4(dir, { recursive: true });
|
|
3147
|
+
const file = join5(dir, `${Date.now()}.json`);
|
|
3071
3148
|
const tmp = `${file}.tmp`;
|
|
3072
3149
|
await writeFile3(tmp, JSON.stringify(messages), "utf8");
|
|
3073
3150
|
await chmod2(tmp, 384).catch(() => {
|
|
@@ -3082,7 +3159,7 @@ var MAX_SESSIONS = 50;
|
|
|
3082
3159
|
async function pruneSessions(dir) {
|
|
3083
3160
|
const files = (await readdir3(dir)).filter((f) => f.endsWith(".json"));
|
|
3084
3161
|
if (files.length <= MAX_SESSIONS) return;
|
|
3085
|
-
for (const f of files.sort().slice(0, files.length - MAX_SESSIONS)) await unlink(
|
|
3162
|
+
for (const f of files.sort().slice(0, files.length - MAX_SESSIONS)) await unlink(join5(dir, f)).catch(() => {
|
|
3086
3163
|
});
|
|
3087
3164
|
}
|
|
3088
3165
|
function sanitizeSession(raw) {
|
|
@@ -3121,7 +3198,7 @@ function dropIncompleteToolTail(messages) {
|
|
|
3121
3198
|
}
|
|
3122
3199
|
async function readSession(file) {
|
|
3123
3200
|
try {
|
|
3124
|
-
const path =
|
|
3201
|
+
const path = join5(sessionsDir(), file);
|
|
3125
3202
|
const parsed = sanitizeSession(JSON.parse(await readFile4(path, "utf8")));
|
|
3126
3203
|
await chmod2(path, 384).catch(() => {
|
|
3127
3204
|
});
|
|
@@ -3162,7 +3239,7 @@ async function loadSession(file) {
|
|
|
3162
3239
|
return await readSession(file) ?? [];
|
|
3163
3240
|
}
|
|
3164
3241
|
function projectApprovalsPath() {
|
|
3165
|
-
return
|
|
3242
|
+
return join5(homedir6(), BEECORK, "project-approvals.json");
|
|
3166
3243
|
}
|
|
3167
3244
|
async function loadProjectApprovals() {
|
|
3168
3245
|
const all = await readJsonFile(projectApprovalsPath());
|
|
@@ -3172,7 +3249,7 @@ async function loadProjectApprovals() {
|
|
|
3172
3249
|
async function addProjectApproval(tool) {
|
|
3173
3250
|
try {
|
|
3174
3251
|
const file = projectApprovalsPath();
|
|
3175
|
-
await
|
|
3252
|
+
await mkdir4(dirname4(file), { recursive: true });
|
|
3176
3253
|
const all = await readJsonFile(file) ?? {};
|
|
3177
3254
|
const list = new Set(Array.isArray(all[projectRoot]) ? all[projectRoot] : []);
|
|
3178
3255
|
list.add(tool);
|
|
@@ -3890,7 +3967,7 @@ function stopChrome() {
|
|
|
3890
3967
|
var chromeEnabled = () => config.statuslineEnabled && !!process.stdout.isTTY;
|
|
3891
3968
|
|
|
3892
3969
|
// src/commands.ts
|
|
3893
|
-
import { writeFile as writeFile4, mkdir as
|
|
3970
|
+
import { writeFile as writeFile4, mkdir as mkdir5, chmod as chmod3 } from "node:fs/promises";
|
|
3894
3971
|
async function pick(opts) {
|
|
3895
3972
|
if (chromeEnabled()) {
|
|
3896
3973
|
const v = await chromePick(opts.items.map((it) => ({ label: it.label, hint: it.hint, value: it.value })), opts.initial, opts.title);
|
|
@@ -4017,7 +4094,7 @@ async function handleCommand(input, messages) {
|
|
|
4017
4094
|
} else if (cmd === "/good" || cmd === "/bad") {
|
|
4018
4095
|
const dir = cmd === "/bad" ? "eval/failures" : "eval/good";
|
|
4019
4096
|
try {
|
|
4020
|
-
await
|
|
4097
|
+
await mkdir5(dir, { recursive: true });
|
|
4021
4098
|
const file = `${dir}/${Date.now()}.json`;
|
|
4022
4099
|
await writeFile4(file, JSON.stringify({ rating: cmd.slice(1), model: state.model, messages }, null, 2), "utf8");
|
|
4023
4100
|
await chmod3(file, 384).catch(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beecork",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.1",
|
|
4
4
|
"description": "beecork — a from-scratch CLI coding agent: multi-model (OpenRouter), BYOK, path-confined tools, built part by part.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"files": [
|
|
11
11
|
"dist/",
|
|
12
|
-
"skills/"
|
|
12
|
+
"skills/",
|
|
13
|
+
"skeleton/"
|
|
13
14
|
],
|
|
14
15
|
"engines": {
|
|
15
16
|
"node": ">=20.12"
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Beecork Skeleton — local inbox (bridge). SHIPPED WITH beecork and auto-started
|
|
2
|
+
// by it (src/skeleton.ts), so the user never runs this by hand. It can still be run
|
|
3
|
+
// directly (`node skeleton/bridge.mjs`) for development.
|
|
4
|
+
//
|
|
5
|
+
// Receives signals POSTed by the extension, keeps a *bounded* rolling window of the
|
|
6
|
+
// most recent ones, and mirrors that window to dev-signals.jsonl (one JSON object per
|
|
7
|
+
// line). Writes are atomic (temp file + rename) so a reader never sees a half file.
|
|
8
|
+
// No dependencies — Node built-ins only.
|
|
9
|
+
//
|
|
10
|
+
// Lifecycle notes (what makes it safe for beecork to own):
|
|
11
|
+
// - Single instance: a second copy racing for the port exits(0) quietly on EADDRINUSE
|
|
12
|
+
// instead of crashing — so parallel beecork sessions share ONE inbox.
|
|
13
|
+
// - Self-tidying: exits itself after IDLE_MS with no traffic and no reads, so an
|
|
14
|
+
// auto-started bridge can't linger forever after the extension/browser is gone.
|
|
15
|
+
// - Fixed home: reads/writes under BEECORK_SKELETON_HOME (beecork points this at
|
|
16
|
+
// ~/.beecork/skeleton) instead of whatever cwd it was launched from.
|
|
17
|
+
|
|
18
|
+
import http from "node:http";
|
|
19
|
+
import { writeFile, readFile, rename } from "node:fs/promises";
|
|
20
|
+
import { resolve } from "node:path";
|
|
21
|
+
import { randomBytes } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
const PORT = Number(process.env.BEECORK_SKELETON_PORT) || 8317;
|
|
24
|
+
const HOME = process.env.BEECORK_SKELETON_HOME || process.cwd();
|
|
25
|
+
const FILE = resolve(HOME, "dev-signals.jsonl");
|
|
26
|
+
const TMP = FILE + ".tmp";
|
|
27
|
+
const TOKEN_FILE = resolve(HOME, ".beecork-token");
|
|
28
|
+
const MAX = 1000; // keep at most this many recent signals
|
|
29
|
+
const IDLE_MS = 60 * 60 * 1000; // self-shutdown after an hour with zero activity
|
|
30
|
+
|
|
31
|
+
// Pairing token: the extension must present this to write. Blocks any other local
|
|
32
|
+
// process or malicious web page from POSTing fake signals to your agent. Generated
|
|
33
|
+
// once and persisted; the extension fetches it via /pair so the user never sees it.
|
|
34
|
+
let TOKEN;
|
|
35
|
+
try {
|
|
36
|
+
TOKEN = (await readFile(TOKEN_FILE, "utf8")).trim();
|
|
37
|
+
if (!TOKEN) throw new Error("empty token file");
|
|
38
|
+
} catch {
|
|
39
|
+
TOKEN = randomBytes(24).toString("hex");
|
|
40
|
+
await writeFile(TOKEN_FILE, TOKEN + "\n", { mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let buffer = []; // rolling in-memory window of recent signal lines; the file mirrors it.
|
|
44
|
+
const watchRequests = new Map(); // origin beecork asked to watch → expiry (ms)
|
|
45
|
+
let lastActivity = Date.now(); // reset on every request; drives idle self-shutdown
|
|
46
|
+
|
|
47
|
+
// Load any existing signals so a bridge restart keeps recent context.
|
|
48
|
+
try {
|
|
49
|
+
const prior = await readFile(FILE, "utf8");
|
|
50
|
+
buffer = prior.split("\n").filter(Boolean).slice(-MAX);
|
|
51
|
+
} catch {
|
|
52
|
+
/* no file yet — start empty */
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Persist the bounded window atomically. Writes are serialized through one chain so
|
|
56
|
+
// two concurrent ingests can't clobber the temp file mid-write.
|
|
57
|
+
let chain = Promise.resolve();
|
|
58
|
+
function persist() {
|
|
59
|
+
chain = chain
|
|
60
|
+
.then(async () => {
|
|
61
|
+
await writeFile(TMP, buffer.length ? buffer.join("\n") + "\n" : "");
|
|
62
|
+
await rename(TMP, FILE);
|
|
63
|
+
})
|
|
64
|
+
.catch((e) => console.error("[skeleton] persist failed:", e));
|
|
65
|
+
return chain;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A web page's fetch carries an http(s) Origin; refuse those so a page you visit can't
|
|
69
|
+
// read your captured app data or drive your extension. beecork's Node fetch has no web
|
|
70
|
+
// origin and passes. No CORS header is set on these routes on purpose.
|
|
71
|
+
const fromWebPage = (req) => /^https?:\/\//i.test(req.headers.origin || "");
|
|
72
|
+
|
|
73
|
+
const server = http.createServer((req, res) => {
|
|
74
|
+
lastActivity = Date.now();
|
|
75
|
+
|
|
76
|
+
// Liveness + identity marker: lets beecork tell OUR bridge apart from some other
|
|
77
|
+
// program that happens to hold the port, so it never spawns a duplicate or talks to
|
|
78
|
+
// a stranger. Non-sensitive, but web-origin-gated like everything else.
|
|
79
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
80
|
+
if (fromWebPage(req)) return void res.writeHead(403).end("forbidden");
|
|
81
|
+
return void res
|
|
82
|
+
.writeHead(200, { "Content-Type": "application/json" })
|
|
83
|
+
.end(JSON.stringify({ skeleton: true, port: PORT, signals: buffer.length }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Auto-pairing: hand the token to the extension so the user never touches it.
|
|
87
|
+
if (req.method === "GET" && req.url === "/pair") {
|
|
88
|
+
if (fromWebPage(req)) return void res.writeHead(403).end("forbidden");
|
|
89
|
+
return void res.writeHead(200, { "Content-Type": "text/plain" }).end(TOKEN);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A distilled read for the agent: recent signals, filtered. ?kind=&since=<epoch-ms>&limit=
|
|
93
|
+
if (req.method === "GET" && req.url.startsWith("/signals")) {
|
|
94
|
+
if (fromWebPage(req)) return void res.writeHead(403).end("forbidden");
|
|
95
|
+
const q = new URL(req.url, "http://localhost").searchParams;
|
|
96
|
+
const kind = q.get("kind");
|
|
97
|
+
const since = Number(q.get("since")) || 0;
|
|
98
|
+
const limit = Math.min(Math.max(Number(q.get("limit")) || 50, 1), 1000);
|
|
99
|
+
let items = buffer.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
100
|
+
if (kind && kind !== "all") items = items.filter((s) => s.kind === kind);
|
|
101
|
+
if (since) items = items.filter((s) => (s.ts || 0) >= since);
|
|
102
|
+
items = items.slice(-limit);
|
|
103
|
+
return void res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ signals: items }));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Reverse channel: beecork asks for a site to be watched. The extension honors it
|
|
107
|
+
// only for sites the user already approved.
|
|
108
|
+
if (req.method === "POST" && req.url === "/request-watch") {
|
|
109
|
+
if (fromWebPage(req)) return void res.writeHead(403).end("forbidden");
|
|
110
|
+
let body = "";
|
|
111
|
+
req.on("data", (c) => (body += c));
|
|
112
|
+
req.on("end", () => {
|
|
113
|
+
try {
|
|
114
|
+
const { origin: site, ttlMs } = JSON.parse(body || "{}");
|
|
115
|
+
if (site) watchRequests.set(String(site), Date.now() + (Number(ttlMs) || 10 * 60 * 1000));
|
|
116
|
+
res.writeHead(200).end("ok");
|
|
117
|
+
} catch {
|
|
118
|
+
res.writeHead(400).end("bad json");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (req.method === "GET" && req.url.startsWith("/watch-requests")) {
|
|
124
|
+
if (fromWebPage(req)) return void res.writeHead(403).end("forbidden");
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
for (const [site, exp] of watchRequests) if (exp < now) watchRequests.delete(site); // expire
|
|
127
|
+
return void res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ origins: [...watchRequests.keys()] }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Permissive CORS so the extension's service worker can POST to /ingest.
|
|
131
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
132
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
133
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
134
|
+
if (req.method === "OPTIONS") return void res.writeHead(204).end();
|
|
135
|
+
|
|
136
|
+
if (req.method === "POST" && req.url === "/ingest") {
|
|
137
|
+
let body = "";
|
|
138
|
+
req.on("data", (c) => (body += c));
|
|
139
|
+
req.on("end", async () => {
|
|
140
|
+
try {
|
|
141
|
+
const signal = JSON.parse(body);
|
|
142
|
+
if (signal.token !== TOKEN) { res.writeHead(401).end("unauthorized"); return; } // token-gated
|
|
143
|
+
delete signal.token; // never persist the token itself
|
|
144
|
+
buffer.push(JSON.stringify(signal));
|
|
145
|
+
if (buffer.length > MAX) buffer.splice(0, buffer.length - MAX); // bound it
|
|
146
|
+
await persist();
|
|
147
|
+
console.log(`[skeleton] ${signal.kind}: ${String(signal.text || "").slice(0, 200)}`);
|
|
148
|
+
res.writeHead(200).end("ok");
|
|
149
|
+
} catch {
|
|
150
|
+
res.writeHead(400).end("bad json");
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
res.writeHead(404).end();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Single-instance: if another bridge already holds the port (a parallel beecork
|
|
159
|
+
// session started it), step aside quietly rather than crash.
|
|
160
|
+
server.on("error", (e) => {
|
|
161
|
+
if (e && e.code === "EADDRINUSE") process.exit(0);
|
|
162
|
+
console.error("[skeleton]", e);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Self-tidying: an auto-started bridge shouldn't outlive its usefulness. When the
|
|
167
|
+
// extension is loaded it polls every ~30s, so this only fires once the browser side
|
|
168
|
+
// is truly gone AND nothing has read for an hour.
|
|
169
|
+
const idle = setInterval(() => {
|
|
170
|
+
if (Date.now() - lastActivity > IDLE_MS) { server.close(); process.exit(0); }
|
|
171
|
+
}, 5 * 60 * 1000);
|
|
172
|
+
idle.unref(); // don't let the timer alone keep the process alive
|
|
173
|
+
|
|
174
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
175
|
+
console.log(`[skeleton] inbox listening on http://localhost:${PORT} (home: ${HOME})`);
|
|
176
|
+
});
|
|
@@ -32,16 +32,18 @@ its own). Then have the user reproduce the issue (or open the site) and call
|
|
|
32
32
|
|
|
33
33
|
## If it says "not connected" — one-time setup
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
**beecork starts the local inbox itself** — the first time you call `read_dev_signals` or
|
|
36
|
+
`watch_site`, beecork auto-starts the bundled bridge (a `127.0.0.1:8317` inbox) in the
|
|
37
|
+
background and shares it across sessions. So there is **no bridge to run by hand**; the only
|
|
38
|
+
one-time step is loading the Chrome extension:
|
|
36
39
|
|
|
37
|
-
1. **
|
|
38
|
-
folder and leave it running. It listens on `localhost:8317`.
|
|
39
|
-
2. **Load the extension:** Chrome → `chrome://extensions` → enable **Developer mode** →
|
|
40
|
+
1. **Load the extension:** Chrome → `chrome://extensions` → enable **Developer mode** →
|
|
40
41
|
**Load unpacked** → select the `beecork-extension/extension` folder → pin the icon.
|
|
41
|
-
|
|
42
|
+
2. **Connect + approve:** click the icon (it auto-connects — no token to paste), tick
|
|
42
43
|
**Capture enabled**, open the app in a tab, and click **Pair this site**.
|
|
43
44
|
|
|
44
|
-
Then call `read_dev_signals` again.
|
|
45
|
+
Then call `read_dev_signals` again. (If beecork reports the inbox port is held by another
|
|
46
|
+
program, free port 8317 or set `BEECORK_DEV_SIGNALS_URL` to a different inbox.)
|
|
45
47
|
|
|
46
48
|
## Empty result
|
|
47
49
|
|