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 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 mkdir2, stat, rename, chmod } from "node:fs/promises";
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 spawn3 } from "node:child_process";
1202
- import { join as join3 } from "node:path";
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 = spawn3(command, { shell: true, detached: unix2, stdio: ["ignore", "pipe", "pipe"] });
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(join3(abs, e.name), prefix + (last ? " " : "\u2502 "), items, cap);
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 DEV_SIGNALS_SETUP = `The browser link isn't connected yet (nothing is responding on localhost:8317).
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 sends the app's console errors and failed network requests to me, so I can see what the browser sees instead of guessing. One-time, local-only setup (no account, no signup).
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
- Walk the user through connecting it:
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 = join3(process.cwd(), ".beecork");
2401
- await mkdir2(dir, { recursive: true });
2402
- const file = join3(dir, "memory.md");
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 base = process.env.BEECORK_DEV_SIGNALS_URL || "http://localhost:8317";
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 bridge may be unhealthy \u2014 try restarting it (node bridge/server.mjs).`;
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 browser link is connected, but no ${kind && kind !== "all" ? `"${kind}" ` : ""}signals were captured${sinceMin ? ` in the last ${sinceMin} min` : ""}. The watched tab just hasn't hit the error yet \u2014 reproduce the issue in the browser (or open the app), then call this again.`;
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
- const base = process.env.BEECORK_DEV_SIGNALS_URL || "http://localhost:8317";
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 mkdir3, chmod as chmod2, rename as rename2, unlink } from "node:fs/promises";
2955
- import { homedir as homedir5 } from "node:os";
2956
- import { join as join4, dirname as dirname3 } from "node:path";
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 = homedir5();
3036
+ const home = homedir6();
2960
3037
  const dirs = [];
2961
3038
  let dir = process.cwd();
2962
- while (dir !== home && dir !== dirname3(dir)) {
3039
+ while (dir !== home && dir !== dirname4(dir)) {
2963
3040
  dirs.push(dir);
2964
- dir = dirname3(dir);
3041
+ dir = dirname4(dir);
2965
3042
  }
2966
3043
  return dirs.reverse();
2967
3044
  }
2968
3045
  function corkPaths() {
2969
- return [join4(homedir5(), BEECORK, "cork.md"), ...ancestorDirs().map((d) => join4(d, "cork.md"))];
3046
+ return [join5(homedir6(), BEECORK, "cork.md"), ...ancestorDirs().map((d) => join5(d, "cork.md"))];
2970
3047
  }
2971
3048
  function beecorkPaths(name) {
2972
- return [join4(homedir5(), BEECORK, name), ...ancestorDirs().map((d) => join4(d, BEECORK, name))];
3049
+ return [join5(homedir6(), BEECORK, name), ...ancestorDirs().map((d) => join5(d, BEECORK, name))];
2973
3050
  }
2974
3051
  function standardInstructionPaths() {
2975
- return ancestorDirs().flatMap((d) => [join4(d, "AGENTS.md"), join4(d, "CLAUDE.md")]);
3052
+ return ancestorDirs().flatMap((d) => [join5(d, "AGENTS.md"), join5(d, "CLAUDE.md")]);
2976
3053
  }
2977
3054
  async function loadInstructions() {
2978
- const home = homedir5();
2979
- const homeBeecork = join4(home, ".beecork");
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 join4(homedir5(), BEECORK, "config.json");
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 mkdir3(dirname3(file), { recursive: true });
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 = join4(homedir5(), BEECORK, "settings.json");
3050
- await mkdir3(dirname3(file), { recursive: true });
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 = join4(homedir5(), BEECORK, "settings.json");
3059
- await mkdir3(dirname3(file), { recursive: true });
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 = () => join4(process.cwd(), BEECORK, "sessions");
3142
+ var sessionsDir = () => join5(process.cwd(), BEECORK, "sessions");
3066
3143
  async function saveSession(messages) {
3067
3144
  try {
3068
3145
  const dir = sessionsDir();
3069
- await mkdir3(dir, { recursive: true });
3070
- const file = join4(dir, `${Date.now()}.json`);
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(join4(dir, f)).catch(() => {
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 = join4(sessionsDir(), file);
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 join4(homedir5(), BEECORK, "project-approvals.json");
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 mkdir3(dirname3(file), { recursive: true });
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 mkdir4, chmod as chmod3 } from "node:fs/promises";
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 mkdir4(dir, { recursive: true });
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.0",
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
- The tool returns setup steps when the bridge isn't running. Relay them to the user:
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. **Start the local inbox (bridge):** run `node bridge/server.mjs` in the `beecork-extension`
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
- 3. **Connect + approve:** click the icon (it auto-connects — no token to paste), tick
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