backthread 0.1.2 → 0.1.4

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.
@@ -7380,6 +7380,104 @@ function sessionTimestamp(records) {
7380
7380
  }
7381
7381
  return latestIso;
7382
7382
  }
7383
+ function stripLeadingSlashes(p) {
7384
+ let i = 0;
7385
+ while (i < p.length && p[i] === "/") i += 1;
7386
+ return p.slice(i);
7387
+ }
7388
+ function isAbsolute(p) {
7389
+ return p.startsWith("/");
7390
+ }
7391
+ function isForeignRelativePath(p) {
7392
+ if (p.startsWith("~")) return true;
7393
+ if (p.startsWith("\\")) return true;
7394
+ if (/^[A-Za-z]:[\\/]/.test(p)) return true;
7395
+ const stripped = p.replace(/^(?:\.[\\/])+/, "");
7396
+ return /^\.\.(?:[\\/]|$)/.test(stripped);
7397
+ }
7398
+ function normalizeRepoRelative(rel) {
7399
+ const out = [];
7400
+ for (const seg of rel.split(/[\\/]/)) {
7401
+ if (seg === "" || seg === ".") continue;
7402
+ if (seg === "..") {
7403
+ if (out.length === 0) return null;
7404
+ out.pop();
7405
+ continue;
7406
+ }
7407
+ out.push(seg);
7408
+ }
7409
+ return out.join("/");
7410
+ }
7411
+ function relativizeUnder(abs, root) {
7412
+ const trimmedRoot = root.replace(/\/+$/, "");
7413
+ if (trimmedRoot.length === 0) return null;
7414
+ if (abs === trimmedRoot) return "";
7415
+ const prefix = trimmedRoot + "/";
7416
+ if (!abs.startsWith(prefix)) return null;
7417
+ return stripLeadingSlashes(abs.slice(trimmedRoot.length));
7418
+ }
7419
+ function pathsFromRecord(rec) {
7420
+ if (!rec || typeof rec !== "object") return [];
7421
+ const out = [];
7422
+ const r = rec;
7423
+ const pushFromInput = (input) => {
7424
+ if (!input || typeof input !== "object") return;
7425
+ const i = input;
7426
+ for (const v of [i.file_path, i.path, i.notebook_path, i.cwd]) {
7427
+ if (typeof v === "string" && v.trim().length > 0) out.push(v.trim());
7428
+ }
7429
+ };
7430
+ const content = r.message?.content;
7431
+ if (Array.isArray(content)) {
7432
+ for (const raw of content) {
7433
+ if (!raw || typeof raw !== "object") continue;
7434
+ const block = raw;
7435
+ if (block.type === "tool_use") pushFromInput(block.input);
7436
+ }
7437
+ }
7438
+ if (r.payload && typeof r.payload === "object" && r.payload.type === "function_call") {
7439
+ const args = r.payload.arguments;
7440
+ if (typeof args === "string") {
7441
+ try {
7442
+ pushFromInput(JSON.parse(args));
7443
+ } catch {
7444
+ }
7445
+ } else {
7446
+ pushFromInput(args);
7447
+ }
7448
+ }
7449
+ return out;
7450
+ }
7451
+ function codexSessionCwd(records) {
7452
+ for (const raw of records) {
7453
+ if (!raw || typeof raw !== "object") continue;
7454
+ const rec = raw;
7455
+ if (rec.type !== "session_meta") continue;
7456
+ const cwd = rec.payload?.cwd;
7457
+ if (typeof cwd === "string" && cwd.trim().length > 0) return cwd.trim();
7458
+ }
7459
+ return null;
7460
+ }
7461
+ function sessionPaths(records, repoRoot) {
7462
+ const root = (repoRoot && repoRoot.trim().length > 0 ? repoRoot.trim() : codexSessionCwd(records)) ?? null;
7463
+ const seen = /* @__PURE__ */ new Set();
7464
+ for (const rec of records) {
7465
+ for (const p of pathsFromRecord(rec)) {
7466
+ if (isAbsolute(p)) {
7467
+ if (root === null) continue;
7468
+ const rel = relativizeUnder(p, root);
7469
+ if (rel === null || rel.length === 0) continue;
7470
+ const norm = normalizeRepoRelative(rel);
7471
+ if (norm === null || norm.length === 0) continue;
7472
+ seen.add(norm);
7473
+ } else if (!isForeignRelativePath(p)) {
7474
+ const norm = normalizeRepoRelative(p.replace(/^(?:\.\/)+/, ""));
7475
+ if (norm !== null && norm.length > 0) seen.add(norm);
7476
+ }
7477
+ }
7478
+ }
7479
+ return Array.from(seen).sort();
7480
+ }
7383
7481
 
7384
7482
  // src/repo.ts
7385
7483
  import { execFileSync } from "node:child_process";
@@ -7459,6 +7557,7 @@ async function serverInfer(transcript, config2, opts = {}) {
7459
7557
  body.persist = true;
7460
7558
  body.repo = { owner: opts.repo.owner, name: opts.repo.name };
7461
7559
  if (opts.decidedAt) body.decidedAt = opts.decidedAt;
7560
+ if (opts.filePaths && opts.filePaths.length > 0) body.filePaths = opts.filePaths;
7462
7561
  }
7463
7562
  let res;
7464
7563
  try {
@@ -7808,7 +7907,9 @@ var TRUST_COPY = [
7808
7907
  " code blocks replaced with [code redacted] \u2014 to Backthread's Worker, never source.",
7809
7908
  " Full details: https://app.backthread.dev/security"
7810
7909
  ].join("\n");
7811
- var HOOK_COMMAND = "npx backthread capture";
7910
+ var HOOK_COMMAND = "npx backthread capture --from-hook --agent claude-code --detach";
7911
+ var LEGACY_HOOK_COMMANDS = ["npx backthread capture"];
7912
+ var OUR_HOOK_COMMANDS = /* @__PURE__ */ new Set([HOOK_COMMAND, ...LEGACY_HOOK_COMMANDS]);
7812
7913
  async function registerHook(cwd, deps = {}) {
7813
7914
  const doReadFile = deps.readFileImpl ?? ((p) => readFile3(p, "utf8"));
7814
7915
  const doWriteFile = deps.writeFileImpl ?? ((p, d) => writeFile3(p, d));
@@ -7854,21 +7955,43 @@ function isNotFound2(err) {
7854
7955
  function mergeSessionEndHook(settings) {
7855
7956
  const hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? { ...settings.hooks } : {};
7856
7957
  const sessionEnd = Array.isArray(hooks.SessionEnd) ? [...hooks.SessionEnd] : [];
7857
- if (sessionEnd.some(groupHasOurCommand)) return null;
7858
- sessionEnd.push({
7859
- hooks: [{ type: "command", command: HOOK_COMMAND }]
7958
+ if (sessionEnd.some((g) => groupHasCommand(g, HOOK_COMMAND))) return null;
7959
+ let migrated = false;
7960
+ const nextSessionEnd = sessionEnd.map((group) => {
7961
+ const rewritten = rewriteLegacyCommand(group);
7962
+ if (rewritten !== group) migrated = true;
7963
+ return rewritten;
7860
7964
  });
7861
- hooks.SessionEnd = sessionEnd;
7965
+ if (!migrated) {
7966
+ nextSessionEnd.push({ hooks: [{ type: "command", command: HOOK_COMMAND }] });
7967
+ }
7968
+ hooks.SessionEnd = nextSessionEnd;
7862
7969
  return { ...settings, hooks };
7863
7970
  }
7864
- function groupHasOurCommand(group) {
7971
+ function groupHasCommand(group, command) {
7865
7972
  if (!group || typeof group !== "object") return false;
7866
7973
  const inner = group.hooks;
7867
7974
  if (!Array.isArray(inner)) return false;
7868
7975
  return inner.some(
7869
- (h) => h && typeof h === "object" && h.command === HOOK_COMMAND
7976
+ (h) => h && typeof h === "object" && h.command === command
7870
7977
  );
7871
7978
  }
7979
+ function rewriteLegacyCommand(group) {
7980
+ if (!group || typeof group !== "object") return group;
7981
+ const inner = group.hooks;
7982
+ if (!Array.isArray(inner)) return group;
7983
+ let changed = false;
7984
+ const nextInner = inner.map((h) => {
7985
+ if (!h || typeof h !== "object") return h;
7986
+ const cmd = h.command;
7987
+ if (typeof cmd === "string" && cmd !== HOOK_COMMAND && OUR_HOOK_COMMANDS.has(cmd)) {
7988
+ changed = true;
7989
+ return { ...h, command: HOOK_COMMAND };
7990
+ }
7991
+ return h;
7992
+ });
7993
+ return changed ? { ...group, hooks: nextInner } : group;
7994
+ }
7872
7995
  async function runInstall(opts = {}, deps = {}) {
7873
7996
  const env = opts.env ?? process.env;
7874
7997
  const log = opts.log ?? ((m) => console.error(m));
@@ -8263,6 +8386,7 @@ async function runCapture(input, deps = {}) {
8263
8386
  const records = parseJsonl(rawTranscript);
8264
8387
  const redacted = redactTranscript(records);
8265
8388
  const decidedAt = sessionTimestamp(records) ?? void 0;
8389
+ const filePaths = sessionPaths(records, input.cwd);
8266
8390
  const sessionId = redacted.sessionId ?? input.session_id ?? null;
8267
8391
  if (redacted.turns.length === 0) {
8268
8392
  return {
@@ -8281,6 +8405,7 @@ async function runCapture(input, deps = {}) {
8281
8405
  env,
8282
8406
  fetchImpl: deps.fetchImpl,
8283
8407
  decidedAt,
8408
+ filePaths,
8284
8409
  ...repo ? { persist: true, repo } : {}
8285
8410
  });
8286
8411
  if (!result.ok) {
package/hooks/hooks.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
- "$comment": "The SessionEnd capture hook, declared in the PLUGIN MANIFEST (referenced from .claude-plugin/plugin.json). When Backthread is installed as a Claude Code plugin, this registers the hook automatically — no mutation of the user's .claude/settings.json. `npx backthread capture` reads the SessionEnd payload off stdin, derives this session's decisions LOCALLY-redacted, and persists them best-effort; it always exits 0 so a capture hiccup can never disrupt the session. Mirrored by the .claude/settings.json fallback that `backthread install` writes for the bare-npx (non-plugin) path. We register ONLY SessionEnd (once per session) on purpose — `runCapture` also handles a Stop payload, but Stop fires on every turn-end, which would capture far too aggressively, so Stop is intentionally NOT registered here.",
2
+ "$comment": "The SessionEnd capture hook, declared in the PLUGIN MANIFEST (referenced from .claude-plugin/plugin.json). When Backthread is installed as a Claude Code plugin, this registers the hook automatically — no mutation of the user's .claude/settings.json. The command routes through the shared `--from-hook` entrypoint with `--detach`: it reads the SessionEnd payload off stdin, then re-spawns a DETACHED worker that does the slow LOCALLY-redacted redact→infer→persist round-trip and returns immediately so a ≥30s inference can't be SIGTERM'd by CC's SessionEnd hook timeout (or reaped on session exit). Best-effort, never blocks/delays the session, always exits 0 (ARP-682). `--agent claude-code` selects the CC payload shape. Mirrored by the .claude/settings.json fallback that `backthread install` writes for the bare-npx (non-plugin) path. We register ONLY SessionEnd (once per session) on purpose — `runCapture` also handles a Stop payload, but Stop fires on every turn-end, which would capture far too aggressively, so Stop is intentionally NOT registered here.",
3
3
  "hooks": {
4
4
  "SessionEnd": [
5
5
  {
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "npx backthread capture"
9
+ "command": "npx backthread capture --from-hook --agent claude-code --detach"
10
10
  }
11
11
  ]
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backthread",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Backthread CLI — capture the why behind your AI-coded changes from your Claude Code sessions, and ask how your codebase works without leaving the terminal. Source code and tool I/O are redacted locally before anything leaves your machine.",
5
5
  "license": "MIT",
6
6
  "author": "Backthread",