beecork 2.7.2 → 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";
@@ -1296,7 +1296,8 @@ function killAllTasks() {
1296
1296
 
1297
1297
  // src/skills.ts
1298
1298
  import { readdir, readFile as readFile2 } from "node:fs/promises";
1299
- import { join as join2 } from "node:path";
1299
+ import { join as join2, dirname as dirname2 } from "node:path";
1300
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1300
1301
  import { homedir as homedir3 } from "node:os";
1301
1302
  function parseSkill(raw) {
1302
1303
  let body = raw;
@@ -1331,9 +1332,11 @@ function getSkill(name) {
1331
1332
  }
1332
1333
  async function loadSkills() {
1333
1334
  registry.clear();
1335
+ const bundledDir = join2(dirname2(fileURLToPath2(import.meta.url)), "..", "skills");
1334
1336
  const dirs = [
1335
1337
  [join2(homedir3(), ".beecork", "skills"), "global"],
1336
- [join2(process.cwd(), ".beecork", "skills"), "project"]
1338
+ [join2(process.cwd(), ".beecork", "skills"), "project"],
1339
+ [bundledDir, "bundled"]
1337
1340
  ];
1338
1341
  for (const [dir, source] of dirs) {
1339
1342
  let entries;
@@ -1346,13 +1349,13 @@ async function loadSkills() {
1346
1349
  if (!e.isFile() || !e.name.endsWith(".md")) continue;
1347
1350
  const name = e.name.slice(0, -3);
1348
1351
  if (!/^[a-z0-9][a-z0-9_-]*$/i.test(name)) continue;
1352
+ if (registry.has(name)) {
1353
+ if (source === "project") console.error(color.yellow(`\u26A0 project skill /${name} ignored \u2014 a global skill of that name takes precedence`));
1354
+ continue;
1355
+ }
1349
1356
  try {
1350
1357
  const raw = (await readFile2(join2(dir, e.name), "utf8")).trim();
1351
1358
  if (!raw) continue;
1352
- if (source === "project" && registry.has(name)) {
1353
- console.error(color.yellow(`\u26A0 project skill /${name} ignored \u2014 a global skill of that name takes precedence`));
1354
- continue;
1355
- }
1356
1359
  const { description, modelInvocable, body } = parseSkill(raw);
1357
1360
  registry.set(name, { name, content: body, description, modelInvocable, path: join2(dir, e.name), source });
1358
1361
  } catch {
@@ -1675,11 +1678,80 @@ function decodeEntities(s) {
1675
1678
  });
1676
1679
  }
1677
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
+
1678
1750
  // src/tools.ts
1679
1751
  function runShell(command, opts) {
1680
1752
  return new Promise((resolve2, reject) => {
1681
1753
  const unix2 = process.platform !== "win32";
1682
- 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"] });
1683
1755
  let stdout = "", stderr = "", outLen = 0, errLen = 0, timedOut = false, aborted = false;
1684
1756
  let settled = false, exitCode = null;
1685
1757
  const kill = () => {
@@ -1961,7 +2033,7 @@ async function walkTree(abs, prefix, items, cap) {
1961
2033
  const e = kept[i];
1962
2034
  const last = i === kept.length - 1;
1963
2035
  items.push({ prefix: prefix + (last ? "\u2514\u2500 " : "\u251C\u2500 "), name: e.name + (e.isDirectory() ? "/" : ""), isDir: e.isDirectory() });
1964
- 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);
1965
2037
  }
1966
2038
  }
1967
2039
  function parseRange(args, defLimit) {
@@ -1991,6 +2063,15 @@ async function readLineWindow(abs, offset1, limit) {
1991
2063
  }
1992
2064
  return { lines, startLine: start + 1, hasMore, empty: i === 0 };
1993
2065
  }
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.
2069
+
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):
2071
+
2072
+ ` + EXTENSION_STEPS + `
2073
+
2074
+ Then call read_dev_signals again. Full step-by-step + troubleshooting is in the "browser-signals" skill.`;
1994
2075
  var toolDefs = [
1995
2076
  {
1996
2077
  name: "read_file",
@@ -2384,9 +2465,9 @@ ${list}`;
2384
2465
  },
2385
2466
  run: async (args) => {
2386
2467
  try {
2387
- const dir = join3(process.cwd(), ".beecork");
2388
- await mkdir2(dir, { recursive: true });
2389
- 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");
2390
2471
  const fact = String(args.fact).trim();
2391
2472
  if (!fact) return 'Error: remember needs a non-empty "fact".';
2392
2473
  let current = "";
@@ -2496,6 +2577,94 @@ ${skill.content}`;
2496
2577
  }
2497
2578
  return `No interactive user is available (headless run). Proceed with the most reasonable option for "${question}" and state the assumption you made.`;
2498
2579
  }
2580
+ },
2581
+ {
2582
+ name: "read_dev_signals",
2583
+ description: "Read the browser's recent console errors and failed network requests for the user's app (localhost or production), captured live by the Beecork Skeleton extension \u2014 so you can SEE what's actually happening instead of guessing. Call this whenever the user reports a bug a browser would surface (blank page, broken button, failed save, a 500, a visual glitch). If it isn't connected yet it returns setup steps to relay to the user. Pull on demand; don't spam it.",
2584
+ parameters: {
2585
+ type: "object",
2586
+ properties: {
2587
+ kind: { type: "string", description: 'Filter: "network", "console", "pageError", "log", or "all" (default all).' },
2588
+ since_minutes: { type: "number", description: "Only signals from the last N minutes (optional)." },
2589
+ limit: { type: "number", description: "Max signals to return (default 30, max 200)." }
2590
+ },
2591
+ required: []
2592
+ },
2593
+ run: async (args, signal) => {
2594
+ const ens = await ensureBridge().catch(() => ({ up: false, reason: "spawn-failed" }));
2595
+ const base = skeletonUrl();
2596
+ const kind = args.kind ? String(args.kind) : "";
2597
+ const limit = Math.min(Math.max(Number(args.limit) || 30, 1), 200);
2598
+ const sinceMin = Number(args.since_minutes) || 0;
2599
+ const params = new URLSearchParams({ limit: String(limit) });
2600
+ if (kind && kind !== "all") params.set("kind", kind);
2601
+ if (sinceMin > 0) params.set("since", String(Date.now() - sinceMin * 6e4));
2602
+ const timeout = AbortSignal.timeout(Math.min(config.webTimeoutMs, 5e3));
2603
+ let data;
2604
+ try {
2605
+ const res = await fetch(`${base}/signals?${params}`, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
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.`;
2607
+ data = await res.json();
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.`;
2610
+ return DEV_SIGNALS_SETUP;
2611
+ }
2612
+ const now = Date.now();
2613
+ const signals = (data.signals ?? []).filter((s) => s && s.kind !== "watch");
2614
+ if (signals.length === 0) {
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.`;
2621
+ }
2622
+ const ago2 = (ts) => ts ? `${Math.max(0, Math.round((now - ts) / 1e3))}s ago` : "";
2623
+ const lines = signals.map((s) => {
2624
+ if (s.kind === "network") return `[network] ${s.method || "GET"} ${s.url ?? ""} \u2192 ${s.status || s.text || "failed"} (${ago2(s.ts)})`;
2625
+ const text = String(s.text ?? "").replace(/\s+/g, " ").slice(0, 300);
2626
+ return `[${s.kind}] ${text}${s.url ? ` @ ${s.url}` : ""} (${ago2(s.ts)})`;
2627
+ });
2628
+ return `${signals.length} browser signal(s), newest last:
2629
+ ${lines.join("\n")}`;
2630
+ }
2631
+ },
2632
+ {
2633
+ name: "watch_site",
2634
+ description: "Ask the Beecork Skeleton extension to start watching an APPROVED site's tab right now \u2014 use for an on-demand or production site you need to investigate (localhost/dev sites are watched automatically, so you don't need this for them). Only sites the user already approved are honored. After calling this, reproduce the issue (or open the site), then read_dev_signals.",
2635
+ parameters: {
2636
+ type: "object",
2637
+ properties: {
2638
+ url: { type: "string", description: "The site to watch, e.g. https://app.example.com (its origin is used)." },
2639
+ minutes: { type: "number", description: "How long to keep watching (default 10, max 120)." }
2640
+ },
2641
+ required: ["url"]
2642
+ },
2643
+ run: async (args, signal) => {
2644
+ await ensureBridge().catch(() => {
2645
+ });
2646
+ const base = skeletonUrl();
2647
+ let origin;
2648
+ try {
2649
+ origin = new URL(String(args.url ?? "")).origin;
2650
+ } catch {
2651
+ return `Error: "${args.url}" is not a valid URL.`;
2652
+ }
2653
+ const minutes = Math.min(Math.max(Number(args.minutes) || 10, 1), 120);
2654
+ const timeout = AbortSignal.timeout(Math.min(config.webTimeoutMs, 5e3));
2655
+ try {
2656
+ const res = await fetch(`${base}/request-watch`, {
2657
+ method: "POST",
2658
+ headers: { "Content-Type": "application/json" },
2659
+ body: JSON.stringify({ origin, ttlMs: minutes * 6e4 }),
2660
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout
2661
+ });
2662
+ if (!res.ok) return `The browser link responded with HTTP ${res.status}.`;
2663
+ } catch {
2664
+ return DEV_SIGNALS_SETUP;
2665
+ }
2666
+ return `Requested watching ${origin} for ${minutes} min. If the user has approved that site in the extension, it will start capturing shortly \u2014 have them reproduce the issue in a tab on ${origin} (or open it), then call read_dev_signals. If nothing shows up, the site isn't approved yet: ask the user to open it and click "Pair this site" in the Beecork Skeleton popup.`;
2667
+ }
2499
2668
  }
2500
2669
  ];
2501
2670
  var TOOLS = toolDefs.map((t) => ({
@@ -2859,32 +3028,32 @@ ${summary}` }, ...recent];
2859
3028
  }
2860
3029
 
2861
3030
  // src/memory.ts
2862
- import { readFile as readFile4, writeFile as writeFile3, readdir as readdir3, mkdir as mkdir3, chmod as chmod2, rename as rename2, unlink } from "node:fs/promises";
2863
- import { homedir as homedir5 } from "node:os";
2864
- import { join as join4, dirname as dirname2 } 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";
2865
3034
  var BEECORK = ".beecork";
2866
3035
  function ancestorDirs() {
2867
- const home = homedir5();
3036
+ const home = homedir6();
2868
3037
  const dirs = [];
2869
3038
  let dir = process.cwd();
2870
- while (dir !== home && dir !== dirname2(dir)) {
3039
+ while (dir !== home && dir !== dirname4(dir)) {
2871
3040
  dirs.push(dir);
2872
- dir = dirname2(dir);
3041
+ dir = dirname4(dir);
2873
3042
  }
2874
3043
  return dirs.reverse();
2875
3044
  }
2876
3045
  function corkPaths() {
2877
- 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"))];
2878
3047
  }
2879
3048
  function beecorkPaths(name) {
2880
- 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))];
2881
3050
  }
2882
3051
  function standardInstructionPaths() {
2883
- 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")]);
2884
3053
  }
2885
3054
  async function loadInstructions() {
2886
- const home = homedir5();
2887
- const homeBeecork = join4(home, ".beecork");
3055
+ const home = homedir6();
3056
+ const homeBeecork = join5(home, ".beecork");
2888
3057
  const trusted = [];
2889
3058
  const project = [];
2890
3059
  const sources = [];
@@ -2937,14 +3106,14 @@ async function loadSettings() {
2937
3106
  return { model, reasoningEffort, alwaysAllow, projectAlwaysAllowIgnored };
2938
3107
  }
2939
3108
  function userConfigPath() {
2940
- return join4(homedir5(), BEECORK, "config.json");
3109
+ return join5(homedir6(), BEECORK, "config.json");
2941
3110
  }
2942
3111
  async function loadUserConfig() {
2943
3112
  return await readJsonFile(userConfigPath()) ?? {};
2944
3113
  }
2945
3114
  async function saveUserConfig(patch) {
2946
3115
  const file = userConfigPath();
2947
- await mkdir3(dirname2(file), { recursive: true });
3116
+ await mkdir4(dirname4(file), { recursive: true });
2948
3117
  const merged = { ...await loadUserConfig(), ...patch };
2949
3118
  const tmp = `${file}.tmp`;
2950
3119
  await writeFile3(tmp, JSON.stringify(merged, null, 2), { encoding: "utf8", mode: 384 });
@@ -2954,8 +3123,8 @@ async function saveUserConfig(patch) {
2954
3123
  }
2955
3124
  async function saveModelPreference(model) {
2956
3125
  try {
2957
- const file = join4(homedir5(), BEECORK, "settings.json");
2958
- await mkdir3(dirname2(file), { recursive: true });
3126
+ const file = join5(homedir6(), BEECORK, "settings.json");
3127
+ await mkdir4(dirname4(file), { recursive: true });
2959
3128
  const current = await readJsonFile(file) ?? {};
2960
3129
  await writeFile3(file, JSON.stringify({ ...current, model }, null, 2), "utf8");
2961
3130
  } catch {
@@ -2963,19 +3132,19 @@ async function saveModelPreference(model) {
2963
3132
  }
2964
3133
  async function saveReasoningPreference(reasoningEffort) {
2965
3134
  try {
2966
- const file = join4(homedir5(), BEECORK, "settings.json");
2967
- await mkdir3(dirname2(file), { recursive: true });
3135
+ const file = join5(homedir6(), BEECORK, "settings.json");
3136
+ await mkdir4(dirname4(file), { recursive: true });
2968
3137
  const current = await readJsonFile(file) ?? {};
2969
3138
  await writeFile3(file, JSON.stringify({ ...current, reasoningEffort }, null, 2), "utf8");
2970
3139
  } catch {
2971
3140
  }
2972
3141
  }
2973
- var sessionsDir = () => join4(process.cwd(), BEECORK, "sessions");
3142
+ var sessionsDir = () => join5(process.cwd(), BEECORK, "sessions");
2974
3143
  async function saveSession(messages) {
2975
3144
  try {
2976
3145
  const dir = sessionsDir();
2977
- await mkdir3(dir, { recursive: true });
2978
- const file = join4(dir, `${Date.now()}.json`);
3146
+ await mkdir4(dir, { recursive: true });
3147
+ const file = join5(dir, `${Date.now()}.json`);
2979
3148
  const tmp = `${file}.tmp`;
2980
3149
  await writeFile3(tmp, JSON.stringify(messages), "utf8");
2981
3150
  await chmod2(tmp, 384).catch(() => {
@@ -2990,7 +3159,7 @@ var MAX_SESSIONS = 50;
2990
3159
  async function pruneSessions(dir) {
2991
3160
  const files = (await readdir3(dir)).filter((f) => f.endsWith(".json"));
2992
3161
  if (files.length <= MAX_SESSIONS) return;
2993
- 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(() => {
2994
3163
  });
2995
3164
  }
2996
3165
  function sanitizeSession(raw) {
@@ -3029,7 +3198,7 @@ function dropIncompleteToolTail(messages) {
3029
3198
  }
3030
3199
  async function readSession(file) {
3031
3200
  try {
3032
- const path = join4(sessionsDir(), file);
3201
+ const path = join5(sessionsDir(), file);
3033
3202
  const parsed = sanitizeSession(JSON.parse(await readFile4(path, "utf8")));
3034
3203
  await chmod2(path, 384).catch(() => {
3035
3204
  });
@@ -3070,7 +3239,7 @@ async function loadSession(file) {
3070
3239
  return await readSession(file) ?? [];
3071
3240
  }
3072
3241
  function projectApprovalsPath() {
3073
- return join4(homedir5(), BEECORK, "project-approvals.json");
3242
+ return join5(homedir6(), BEECORK, "project-approvals.json");
3074
3243
  }
3075
3244
  async function loadProjectApprovals() {
3076
3245
  const all = await readJsonFile(projectApprovalsPath());
@@ -3080,7 +3249,7 @@ async function loadProjectApprovals() {
3080
3249
  async function addProjectApproval(tool) {
3081
3250
  try {
3082
3251
  const file = projectApprovalsPath();
3083
- await mkdir3(dirname2(file), { recursive: true });
3252
+ await mkdir4(dirname4(file), { recursive: true });
3084
3253
  const all = await readJsonFile(file) ?? {};
3085
3254
  const list = new Set(Array.isArray(all[projectRoot]) ? all[projectRoot] : []);
3086
3255
  list.add(tool);
@@ -3798,7 +3967,7 @@ function stopChrome() {
3798
3967
  var chromeEnabled = () => config.statuslineEnabled && !!process.stdout.isTTY;
3799
3968
 
3800
3969
  // src/commands.ts
3801
- 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";
3802
3971
  async function pick(opts) {
3803
3972
  if (chromeEnabled()) {
3804
3973
  const v = await chromePick(opts.items.map((it) => ({ label: it.label, hint: it.hint, value: it.value })), opts.initial, opts.title);
@@ -3925,7 +4094,7 @@ async function handleCommand(input, messages) {
3925
4094
  } else if (cmd === "/good" || cmd === "/bad") {
3926
4095
  const dir = cmd === "/bad" ? "eval/failures" : "eval/good";
3927
4096
  try {
3928
- await mkdir4(dir, { recursive: true });
4097
+ await mkdir5(dir, { recursive: true });
3929
4098
  const file = `${dir}/${Date.now()}.json`;
3930
4099
  await writeFile4(file, JSON.stringify({ rating: cmd.slice(1), model: state.model, messages }, null, 2), "utf8");
3931
4100
  await chmod3(file, 384).catch(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beecork",
3
- "version": "2.7.2",
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": {
@@ -8,7 +8,9 @@
8
8
  },
9
9
  "main": "dist/index.js",
10
10
  "files": [
11
- "dist/"
11
+ "dist/",
12
+ "skills/",
13
+ "skeleton/"
12
14
  ],
13
15
  "engines": {
14
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
+ });
@@ -0,0 +1,61 @@
1
+ ---
2
+ description: Set up / use the browser link so beecork sees the app's console + network errors (read_dev_signals)
3
+ ---
4
+
5
+ # Browser signals — let beecork see the running app
6
+
7
+ You can read the user's app **console errors** and **failed network requests** straight
8
+ from the browser — on localhost *or* production, in their real logged-in session — with the
9
+ `read_dev_signals` tool. No copy-pasting errors. This is the **Beecork Skeleton** Chrome
10
+ extension plus a tiny local inbox (the "bridge").
11
+
12
+ ## When to use `read_dev_signals`
13
+
14
+ Call it whenever the user reports something a browser would surface — a blank page, a broken
15
+ button, a form that won't submit, a save that 500s, a visual glitch. Instead of guessing, pull
16
+ the real errors:
17
+
18
+ - `read_dev_signals({ kind: "network" })` — failed requests (status ≥ 400 / network failures)
19
+ - `read_dev_signals({ since_minutes: 5 })` — everything captured in the last 5 minutes
20
+ - `read_dev_signals({})` — the most recent signals of any kind
21
+
22
+ Pull it **on demand** while debugging — don't call it in a loop or on every turn.
23
+
24
+ ## Watching an on-demand / production site
25
+
26
+ Localhost/dev sites the user approved are watched automatically. A **production** (or
27
+ any "on-demand") approved site is idle until asked. To investigate one, call
28
+ `watch_site({ url })` — it asks the extension to watch that site for a while. Only sites
29
+ the user already approved are honored (beecork can't start watching a brand-new site on
30
+ its own). Then have the user reproduce the issue (or open the site) and call
31
+ `read_dev_signals`.
32
+
33
+ ## If it says "not connected" — one-time setup
34
+
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:
39
+
40
+ 1. **Load the extension:** Chrome → `chrome://extensions` → enable **Developer mode** →
41
+ **Load unpacked** → select the `beecork-extension/extension` folder → pin the icon.
42
+ 2. **Connect + approve:** click the icon (it auto-connects — no token to paste), tick
43
+ **Capture enabled**, open the app in a tab, and click **Pair this site**.
44
+
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.)
47
+
48
+ ## Empty result
49
+
50
+ If it connects but returns nothing, the watched tab just hasn't hit the error yet. Either ask
51
+ the user to reproduce it, or open the app yourself to trigger it — e.g.
52
+ `open -a "Google Chrome" http://localhost:3000` (macOS) — then reproduce the action and call
53
+ `read_dev_signals` again.
54
+
55
+ ## How it stays safe
56
+
57
+ - **Approved sites only** — nothing is watched except sites the user approved in the popup.
58
+ - **Secrets redacted in the browser** — tokens, API keys, passwords, and Authorization values
59
+ are stripped *before* any signal leaves the tab, so they never reach the inbox.
60
+ - **Local + authenticated** — the inbox is `127.0.0.1` only, and only the extension can write to
61
+ it (an automatic local token; a web page you visit cannot).