behavior-wrapped 0.4.1 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "behavior-wrapped",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "A private, local-first Wrapped report for Claude Code, Cowork, and Codex behavior.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/server/cli.mjs CHANGED
@@ -14,6 +14,8 @@ import { deletePublicReport, publishPublicReport, PUBLIC_REPORT_ORIGIN } from ".
14
14
  import { createReportId, deleteReport, getOrCreateClientId, listReports, saveReport, storeRoot } from "./store.mjs";
15
15
  import { judgeErrorDetails } from "./judge-debug.mjs";
16
16
  import { createCliProgress } from "./progress.mjs";
17
+ import { helperHealthMatches, stopVerifiedStaleHelper } from "./local-helper-runtime.mjs";
18
+ import { APP_VERSION, LOCAL_DONATION_PROTOCOL } from "./runtime-version.mjs";
17
19
 
18
20
  const here = path.dirname(fileURLToPath(import.meta.url));
19
21
  const root = path.dirname(here);
@@ -68,21 +70,31 @@ function printJudgeDebug(label, error) {
68
70
  console.error(JSON.stringify(judgeErrorDetails(error), null, 2));
69
71
  }
70
72
 
71
- async function serverReady(expectedDemo = false) {
73
+ async function helperStatus(expectedDemo = false) {
72
74
  try {
73
75
  const response = await fetch(`${loopbackUrl}/api/health`);
74
76
  const body = await response.json();
75
- return response.ok && body.app === "behavior-wrapped" && Boolean(body.demo) === expectedDemo;
76
- } catch { return false; }
77
+ const recognized = response.ok && body.app === "behavior-wrapped" && body.local === true && body.purpose === "research-donation";
78
+ return { recognized, compatible: recognized && helperHealthMatches(body, { version: APP_VERSION, protocol: LOCAL_DONATION_PROTOCOL, demo: expectedDemo }), pid: Number(body.pid) || null };
79
+ } catch { return { recognized: false, compatible: false, pid: null }; }
77
80
  }
78
81
 
79
82
  async function ensureServer(demo = false) {
80
- if (await serverReady(demo)) return;
83
+ const current = await helperStatus(demo);
84
+ if (current.compatible) return;
85
+ if (current.recognized) {
86
+ const stopped = await stopVerifiedStaleHelper(port, current.pid);
87
+ if (!stopped) throw new Error(`An older Behavior Wrapped helper is using port ${port}. Stop it, then run this command again.`);
88
+ for (let attempt = 0; attempt < 30; attempt++) {
89
+ await new Promise((resolve) => setTimeout(resolve, 100));
90
+ if (!(await helperStatus(demo)).recognized) break;
91
+ }
92
+ }
81
93
  const child = spawn(process.execPath, [path.join(here, "launcher.mjs"), `--port=${port}`, "--no-open", ...(demo ? ["--demo"] : [])], { detached: true, stdio: "ignore", env: { ...process.env, BEHAVIOR_WRAPPED_DAEMON: "1" } });
82
94
  child.unref();
83
95
  for (let attempt = 0; attempt < 30; attempt++) {
84
96
  await new Promise((resolve) => setTimeout(resolve, 100));
85
- if (await serverReady(demo)) return;
97
+ if ((await helperStatus(demo)).compatible) return;
86
98
  }
87
99
  throw new Error(`Could not start the local donation helper on port ${port}.`);
88
100
  }
@@ -9,6 +9,7 @@ import { discoverAllSessionsAsync, readRecordsAsync, defaultDateRange, DEFAULT_W
9
9
  import { makeDonationPreview } from "./analysis.mjs";
10
10
  import { deleteDonationReceipt, getOrCreateClientId, loadDonationReceipt, loadReport, saveDonationReceipt } from "./store.mjs";
11
11
  import { deleteResearchDonation, RESEARCH_DONATION_URL, submitResearchDonation } from "./research-donation.mjs";
12
+ import { APP_VERSION, LOCAL_DONATION_PROTOCOL } from "./runtime-version.mjs";
12
13
 
13
14
  const here = path.dirname(fileURLToPath(import.meta.url));
14
15
  const root = path.dirname(here);
@@ -90,7 +91,7 @@ const server = http.createServer(async (request, response) => {
90
91
  try {
91
92
  if (!new Set([`127.0.0.1:${port}`, `localhost:${port}`]).has(request.headers.host || "")) return json(response, 403, { error: "Local access only" });
92
93
  const url = new URL(request.url || "/", `http://${request.headers.host}`);
93
- if (request.method === "GET" && url.pathname === "/api/health") return json(response, 200, { app: "behavior-wrapped", local: true, purpose: "research-donation", demo });
94
+ if (request.method === "GET" && url.pathname === "/api/health") return json(response, 200, { app: "behavior-wrapped", version: APP_VERSION, local: true, purpose: "research-donation", donationProtocol: LOCAL_DONATION_PROTOCOL, pid: process.pid, demo });
94
95
  if (request.method === "GET" && url.pathname === "/api/discover") {
95
96
  catalog = await loadCatalog();
96
97
  return json(response, 200, publicCatalog());
@@ -0,0 +1,37 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ function run(file, args) {
4
+ return new Promise((resolve, reject) => execFile(file, args, { encoding: "utf8" }, (error, stdout) => error ? reject(error) : resolve(stdout)));
5
+ }
6
+
7
+ export function helperHealthMatches(value, { version, protocol, demo }) {
8
+ return Boolean(value && value.app === "behavior-wrapped" && value.local === true && value.purpose === "research-donation"
9
+ && value.version === version && value.donationProtocol === protocol && Boolean(value.demo) === Boolean(demo));
10
+ }
11
+
12
+ export function isVerifiedLauncherCommand(command, port) {
13
+ const escapedPort = String(port).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
+ return new RegExp(`/(?:agent-)?behavior-wrapped/server/launcher\\.mjs(?:\\s|$)`, "i").test(command || "")
15
+ && new RegExp(`(?:^|\\s)--port=${escapedPort}(?:\\s|$)`).test(command || "");
16
+ }
17
+
18
+ async function listeningPids(port, runCommand) {
19
+ try {
20
+ const output = await runCommand("/usr/sbin/lsof", ["-nP", "-t", `-iTCP:${port}`, "-sTCP:LISTEN"]);
21
+ return String(output).split(/\s+/).filter((value) => /^\d+$/.test(value)).map(Number);
22
+ } catch { return []; }
23
+ }
24
+
25
+ export async function stopVerifiedStaleHelper(port, advertisedPid, { runCommand = run, kill = process.kill } = {}) {
26
+ const candidates = Number.isInteger(advertisedPid) && advertisedPid > 1 ? [advertisedPid] : await listeningPids(port, runCommand);
27
+ let stopped = false;
28
+ for (const pid of candidates) {
29
+ let command;
30
+ try { command = await runCommand("/bin/ps", ["-p", String(pid), "-o", "command="]); }
31
+ catch { continue; }
32
+ if (!isVerifiedLauncherCommand(String(command).trim(), port)) continue;
33
+ try { kill(pid, "SIGTERM"); stopped = true; }
34
+ catch (error) { if (error?.code !== "ESRCH") throw error; }
35
+ }
36
+ return stopped;
37
+ }
@@ -0,0 +1,6 @@
1
+ import fs from "node:fs";
2
+
3
+ const packageJson = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
4
+
5
+ export const APP_VERSION = packageJson.version;
6
+ export const LOCAL_DONATION_PROTOCOL = 2;