backthread 0.1.3 → 0.2.0

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.
@@ -2,10 +2,16 @@
2
2
  "name": "backthread",
3
3
  "displayName": "Backthread",
4
4
  "description": "Keep the thread on what the agent shipped: Backthread captures the \"why\" of every session and answers \"how does X work?\" on a live \"how it works\" diagram — no diving through PRs. Provides the /backthread:capture slash command, the SessionEnd capture hook, and the backthread MCP server (capture + query).",
5
- "version": "0.1.0",
5
+ "version": "0.2.0",
6
6
  "author": {
7
7
  "name": "Backthread"
8
8
  },
9
9
  "homepage": "https://backthread.dev",
10
- "hooks": "./hooks/hooks.json"
10
+ "hooks": "./hooks/hooks.json",
11
+ "mcpServers": {
12
+ "backthread": {
13
+ "command": "node",
14
+ "args": ["${CLAUDE_PLUGIN_ROOT}/dist-bundle/backthread.js", "mcp"]
15
+ }
16
+ }
11
17
  }
package/README.md CHANGED
@@ -38,6 +38,24 @@ can verify it — read more at [backthread.dev/security](https://backthread.dev/
38
38
 
39
39
  ## Quick start
40
40
 
41
+ ### Claude Code → install the plugin (one click, recommended)
42
+
43
+ In Claude Code:
44
+
45
+ ```
46
+ /plugin marketplace add backthread/backthread
47
+ /plugin install backthread@backthread
48
+ /backthread:start
49
+ ```
50
+
51
+ Installing the plugin bundles the CLI — no separate npm step — and registers, at
52
+ **user/global scope** (so it works across every repo and git worktree), the
53
+ SessionEnd **capture hook**, the `/backthread:capture` & `/backthread:start`
54
+ commands, and the **backthread MCP server** (capture + `query`). `/backthread:start`
55
+ just signs you in. That's the whole setup.
56
+
57
+ ### Any agent (or bare terminal) → `npx backthread install`
58
+
41
59
  In your project:
42
60
 
43
61
  ```bash
@@ -57,6 +75,23 @@ That's the whole setup. `install`:
57
75
  Already added Backthread as a Claude Code plugin? The hook is wired for you —
58
76
  run `/backthread:start` (or `npx backthread start`) just to sign in.
59
77
 
78
+ ### Codex / Cursor / Gemini CLI → `npx backthread install --agent <agent>`
79
+
80
+ Use another coding agent? One command wires up its **MCP server** (the `query`
81
+ tool) **and** an automatic capture hook — written to that agent's **user-global**
82
+ config so capture follows you across every repo and git worktree:
83
+
84
+ ```bash
85
+ npx backthread install --agent codex # ~/.codex/config.toml + ~/.codex/hooks.json
86
+ npx backthread install --agent cursor # ~/.cursor/mcp.json + ~/.cursor/hooks.json
87
+ npx backthread install --agent gemini # ~/.gemini/settings.json (MCP + SessionEnd hook)
88
+ ```
89
+
90
+ It's idempotent (re-running never duplicates anything) and a strict merge (it never
91
+ clobbers your other config). Then `npx backthread login` once to authorize. Gemini
92
+ users can also install the [one-command extension](https://github.com/backthread/backthread/tree/main/extensions/gemini)
93
+ instead, and Codex users the [plugin](https://github.com/backthread/backthread/tree/main/extensions/codex).
94
+
60
95
  ## Onboard yourself in 3 steps
61
96
 
62
97
  1. **Install** — `npx backthread install` in your repo. One browser click to authorize.
@@ -17,7 +17,7 @@ summary tells you to re-run with an explicit path:
17
17
 
18
18
  ## Capture result
19
19
 
20
- !`npx backthread capture --manual --session "${CLAUDE_SESSION_ID}" --cwd "$(pwd)" $ARGUMENTS`
20
+ !`BT="${CLAUDE_PLUGIN_ROOT}/dist-bundle/backthread.js"; if [ -f "$BT" ]; then node "$BT" capture --manual --session "${CLAUDE_SESSION_ID}" --cwd "$(pwd)" $ARGUMENTS; else npx backthread capture --manual --session "${CLAUDE_SESSION_ID}" --cwd "$(pwd)" $ARGUMENTS; fi`
21
21
 
22
22
  ## Your task
23
23
 
package/commands/start.md CHANGED
@@ -16,7 +16,7 @@ If the web app handed you a claim code, pass it: `/backthread:start --claim <cod
16
16
 
17
17
  ## Setup result
18
18
 
19
- !`npx backthread start $ARGUMENTS`
19
+ !`BT="${CLAUDE_PLUGIN_ROOT}/dist-bundle/backthread.js"; if [ -f "$BT" ]; then node "$BT" start $ARGUMENTS; else npx backthread start $ARGUMENTS; fi`
20
20
 
21
21
  ## Your task
22
22
 
@@ -7289,7 +7289,7 @@ async function ensureAuth(opts = {}) {
7289
7289
  }
7290
7290
 
7291
7291
  // src/capture.ts
7292
- import { readFile as readFile5 } from "node:fs/promises";
7292
+ import { readFile as readFile8 } from "node:fs/promises";
7293
7293
 
7294
7294
  // ../packages/redact/src/index.ts
7295
7295
  var CODE_REDACTION = "[code redacted]";
@@ -7395,6 +7395,19 @@ function isForeignRelativePath(p) {
7395
7395
  const stripped = p.replace(/^(?:\.[\\/])+/, "");
7396
7396
  return /^\.\.(?:[\\/]|$)/.test(stripped);
7397
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
+ }
7398
7411
  function relativizeUnder(abs, root) {
7399
7412
  const trimmedRoot = root.replace(/\/+$/, "");
7400
7413
  if (trimmedRoot.length === 0) return null;
@@ -7454,10 +7467,12 @@ function sessionPaths(records, repoRoot) {
7454
7467
  if (root === null) continue;
7455
7468
  const rel = relativizeUnder(p, root);
7456
7469
  if (rel === null || rel.length === 0) continue;
7457
- seen.add(rel);
7470
+ const norm = normalizeRepoRelative(rel);
7471
+ if (norm === null || norm.length === 0) continue;
7472
+ seen.add(norm);
7458
7473
  } else if (!isForeignRelativePath(p)) {
7459
- const rel = p.replace(/^(?:\.\/)+/, "");
7460
- if (rel.length > 0) seen.add(rel);
7474
+ const norm = normalizeRepoRelative(p.replace(/^(?:\.\/)+/, ""));
7475
+ if (norm !== null && norm.length > 0) seen.add(norm);
7461
7476
  }
7462
7477
  }
7463
7478
  }
@@ -7702,17 +7717,13 @@ async function maybeNudge(status, repo, sessionId, deps = {}) {
7702
7717
  }
7703
7718
 
7704
7719
  // src/firstRun.ts
7705
- import { join as join7 } from "node:path";
7706
- import { readFile as readFile4, writeFile as writeFile4, mkdir as mkdir4, chmod as chmod3 } from "node:fs/promises";
7720
+ import { join as join9 } from "node:path";
7721
+ import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir6, chmod as chmod4 } from "node:fs/promises";
7707
7722
 
7708
7723
  // src/install.ts
7709
- import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir3 } from "node:fs/promises";
7710
- import { join as join6 } from "node:path";
7711
-
7712
- // src/backfill.ts
7713
- import { readdir } from "node:fs/promises";
7714
- import { homedir as homedir3 } from "node:os";
7715
- import { join as join5 } from "node:path";
7724
+ import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir5 } from "node:fs/promises";
7725
+ import { homedir as homedir5 } from "node:os";
7726
+ import { join as join8 } from "node:path";
7716
7727
 
7717
7728
  // src/captureCommand.ts
7718
7729
  import { stat } from "node:fs/promises";
@@ -7822,9 +7833,135 @@ function parseManualArgs(argv) {
7822
7833
  return { manual, input };
7823
7834
  }
7824
7835
 
7825
- // src/backfill.ts
7826
- function claudeProjectsDir(cwd, home) {
7827
- return join5(home, ".claude", "projects", slugifyCwd(cwd));
7836
+ // src/sweep.ts
7837
+ import { readFile as readFile4, stat as stat2, readdir } from "node:fs/promises";
7838
+ import { execFileSync as execFileSync2 } from "node:child_process";
7839
+ import { homedir as homedir3 } from "node:os";
7840
+ import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join6 } from "node:path";
7841
+
7842
+ // src/sweepLedger.ts
7843
+ import { join as join5 } from "node:path";
7844
+ import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir3, chmod as chmod3 } from "node:fs/promises";
7845
+ var MAX_PROCESSED = 2e4;
7846
+ function sweepStatePath(env = process.env) {
7847
+ return join5(configDir(env), "sweep-state.json");
7848
+ }
7849
+ function parseSweepState(raw) {
7850
+ try {
7851
+ const obj = JSON.parse(raw);
7852
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
7853
+ const rec = obj;
7854
+ const processed = Array.isArray(rec.processed) ? rec.processed.filter((s) => typeof s === "string") : [];
7855
+ const lastSweptAt = {};
7856
+ if (rec.lastSweptAt && typeof rec.lastSweptAt === "object" && !Array.isArray(rec.lastSweptAt)) {
7857
+ for (const [k, v] of Object.entries(rec.lastSweptAt)) {
7858
+ if (typeof v === "string" && v.length > 0) lastSweptAt[k] = v;
7859
+ }
7860
+ }
7861
+ return { processed, lastSweptAt };
7862
+ }
7863
+ } catch {
7864
+ }
7865
+ return { processed: [], lastSweptAt: {} };
7866
+ }
7867
+ function serializeSweepState(state) {
7868
+ return JSON.stringify({ processed: state.processed, lastSweptAt: state.lastSweptAt }) + "\n";
7869
+ }
7870
+ async function readSweepState(env = process.env) {
7871
+ try {
7872
+ return parseSweepState(await readFile3(sweepStatePath(env), "utf8"));
7873
+ } catch {
7874
+ return { processed: [], lastSweptAt: {} };
7875
+ }
7876
+ }
7877
+ async function writeSweepState(state, env = process.env) {
7878
+ try {
7879
+ const dir = configDir(env);
7880
+ await mkdir3(dir, { recursive: true, mode: DIR_MODE });
7881
+ await chmod3(dir, DIR_MODE).catch(() => {
7882
+ });
7883
+ const path = sweepStatePath(env);
7884
+ await writeFile3(path, serializeSweepState(state), { mode: CONFIG_MODE });
7885
+ await chmod3(path, CONFIG_MODE).catch(() => {
7886
+ });
7887
+ } catch {
7888
+ }
7889
+ }
7890
+ function addProcessed(state, sessionIds) {
7891
+ const seen = new Set(state.processed);
7892
+ const next = [...state.processed];
7893
+ for (const sid of sessionIds) {
7894
+ if (!sid || seen.has(sid)) continue;
7895
+ seen.add(sid);
7896
+ next.push(sid);
7897
+ }
7898
+ if (next.length > MAX_PROCESSED) next.splice(0, next.length - MAX_PROCESSED);
7899
+ return { ...state, processed: next };
7900
+ }
7901
+ async function markSweepProcessed(sessionId, env = process.env) {
7902
+ if (!sessionId || sessionId.trim().length === 0) return;
7903
+ try {
7904
+ const state = await readSweepState(env);
7905
+ if (state.processed.includes(sessionId)) return;
7906
+ await writeSweepState(addProcessed(state, [sessionId]), env);
7907
+ } catch {
7908
+ }
7909
+ }
7910
+
7911
+ // src/sweep.ts
7912
+ var SWEEP_DEBOUNCE_MS = 15 * 60 * 1e3;
7913
+ var EMPTY = (status, repo, text) => ({
7914
+ status,
7915
+ repo,
7916
+ dirsSwept: 0,
7917
+ found: 0,
7918
+ skipped: 0,
7919
+ captured: 0,
7920
+ decisions: 0,
7921
+ attributions: [],
7922
+ text
7923
+ });
7924
+ function isTerminallyProcessed(outcome) {
7925
+ return outcome.status === "persisted" || outcome.status === "persisted-by-server" || outcome.status === "nothing-to-capture";
7926
+ }
7927
+ function syntheticRemote(repo) {
7928
+ return `https://github.com/${repo.owner}/${repo.name}.git`;
7929
+ }
7930
+ function extractCwdFromRaw(raw) {
7931
+ for (const line of raw.split("\n")) {
7932
+ const trimmed = line.trim();
7933
+ if (!trimmed) continue;
7934
+ let rec;
7935
+ try {
7936
+ rec = JSON.parse(trimmed);
7937
+ } catch {
7938
+ continue;
7939
+ }
7940
+ if (!rec || typeof rec !== "object") continue;
7941
+ const r = rec;
7942
+ if (typeof r.cwd === "string" && r.cwd.trim().length > 0) return r.cwd.trim();
7943
+ if (r.type === "session_meta" && typeof r.payload?.cwd === "string" && r.payload.cwd.trim().length > 0) {
7944
+ return r.payload.cwd.trim();
7945
+ }
7946
+ }
7947
+ return null;
7948
+ }
7949
+ function classifyDir(args) {
7950
+ const { dirName, mainSlug, mainRoot, embeddedCwd, cwdExists, resolved, target } = args;
7951
+ if (dirName !== mainSlug && !dirName.startsWith(mainSlug + "-")) {
7952
+ return { include: false, mode: "unattributable", cwd: embeddedCwd };
7953
+ }
7954
+ if (!embeddedCwd) return { include: false, mode: "unattributable", cwd: null };
7955
+ if (cwdExists) {
7956
+ if (resolved && resolved.owner === target.owner && resolved.name === target.name) {
7957
+ return { include: true, mode: "git", cwd: embeddedCwd };
7958
+ }
7959
+ return { include: false, mode: "excluded-other-repo", cwd: embeddedCwd };
7960
+ }
7961
+ const isNested = embeddedCwd === mainRoot || embeddedCwd.startsWith(mainRoot + "/");
7962
+ const isSibling = embeddedCwd.startsWith(mainRoot + "-");
7963
+ if (isNested || isSibling) return { include: true, mode: "heuristic", cwd: embeddedCwd };
7964
+ return { include: false, mode: "unattributable", cwd: embeddedCwd };
7828
7965
  }
7829
7966
  async function defaultReadDir(dir) {
7830
7967
  try {
@@ -7833,53 +7970,412 @@ async function defaultReadDir(dir) {
7833
7970
  return [];
7834
7971
  }
7835
7972
  }
7836
- async function runBackfill(input = {}, deps = {}) {
7837
- const log = deps.log ?? ((m) => console.error(m));
7838
- const home = (deps.homedirImpl ?? homedir3)();
7839
- const cwd = input.cwd ?? process.cwd();
7840
- const doReadDir = deps.readDirImpl ?? defaultReadDir;
7841
- const run = deps.runCaptureImpl ?? runCapture;
7842
- const dir = claudeProjectsDir(cwd, home);
7843
- let entries;
7973
+ async function defaultPathExists(path) {
7844
7974
  try {
7845
- entries = await doReadDir(dir);
7975
+ await stat2(path);
7976
+ return true;
7846
7977
  } catch {
7847
- entries = [];
7848
- }
7849
- const files = entries.filter((n) => n.endsWith(".jsonl")).sort();
7850
- if (files.length === 0) {
7851
- const text2 = "backthread backfill: no past Claude Code sessions found for this repo \u2014 nothing to backfill. Live capture is armed; your decision log fills as you work.";
7852
- log(text2);
7853
- return { found: 0, captured: 0, decisions: 0, results: [], text: text2 };
7978
+ return false;
7854
7979
  }
7855
- log(
7856
- `backthread backfill: found ${files.length} past Claude Code session(s) for this repo \u2014 seeding your decision log (best-effort, this never blocks)\u2026`
7857
- );
7858
- const results = [];
7859
- let captured = 0;
7860
- let decisions = 0;
7861
- for (const file2 of files) {
7862
- const hookInput = {
7863
- transcript_path: join5(dir, file2),
7980
+ }
7981
+ function defaultMainRoot(cwd) {
7982
+ try {
7983
+ const out = execFileSync2("git", ["rev-parse", "--git-common-dir"], {
7864
7984
  cwd,
7865
- hook_event_name: "SessionEnd"
7866
- };
7867
- let outcome;
7985
+ encoding: "utf8",
7986
+ stdio: ["ignore", "pipe", "ignore"]
7987
+ }).trim();
7988
+ if (!out) return null;
7989
+ const abs = isAbsolute2(out) ? out : join6(cwd, out);
7990
+ return dirname2(abs.replace(/\/+$/, ""));
7991
+ } catch {
7992
+ return null;
7993
+ }
7994
+ }
7995
+ async function runSweep(input = {}, deps = {}) {
7996
+ const env = deps.env ?? process.env;
7997
+ const log = deps.log ?? ((m) => console.error(m));
7998
+ const debounceMs = deps.debounceMs ?? SWEEP_DEBOUNCE_MS;
7999
+ const baseReadDir = deps.readDirImpl ?? defaultReadDir;
8000
+ const doReadDir = async (d) => {
7868
8001
  try {
7869
- outcome = await run(hookInput, deps.captureDeps);
7870
- } catch (e) {
7871
- outcome = { status: "error", detail: `capture threw (swallowed): ${e.message}` };
8002
+ return await baseReadDir(d);
8003
+ } catch {
8004
+ return [];
8005
+ }
8006
+ };
8007
+ const baseReadFile = deps.readFileImpl ?? ((p) => readFile4(p, "utf8"));
8008
+ const doReadFile = async (p) => {
8009
+ try {
8010
+ return await baseReadFile(p);
8011
+ } catch {
8012
+ return "";
8013
+ }
8014
+ };
8015
+ const basePathExists = deps.pathExistsImpl ?? defaultPathExists;
8016
+ const doPathExists = async (p) => {
8017
+ try {
8018
+ return await basePathExists(p);
8019
+ } catch {
8020
+ return false;
7872
8021
  }
7873
- results.push({ file: file2, outcome });
7874
- if (outcome.status === "persisted" || outcome.status === "persisted-by-server") {
7875
- captured += 1;
7876
- decisions += typeof outcome.count === "number" ? outcome.count : 0;
8022
+ };
8023
+ const doMainRoot = (c) => {
8024
+ try {
8025
+ return (deps.mainRootImpl ?? defaultMainRoot)(c);
8026
+ } catch {
8027
+ return null;
7877
8028
  }
7878
- log(` ${file2}: ${outcome.status}${typeof outcome.count === "number" ? ` (${outcome.count})` : ""}`);
8029
+ };
8030
+ const readRemote = deps.readRemoteImpl;
8031
+ const doReadConfig = deps.readConfigImpl ?? readConfig;
8032
+ const run = deps.runCaptureImpl ?? runCapture;
8033
+ const doReadState = deps.readSweepStateImpl ?? readSweepState;
8034
+ const doWriteState = deps.writeSweepStateImpl ?? writeSweepState;
8035
+ try {
8036
+ const home = (deps.homedirImpl ?? homedir3)();
8037
+ const now = (deps.nowImpl ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
8038
+ const cwd = input.cwd ?? process.cwd();
8039
+ const target = resolveRepo(cwd, readRemote);
8040
+ if (!target) {
8041
+ const text2 = `backthread sweep: no git remote for ${cwd} \u2014 can't scope a gap-recovery sweep.`;
8042
+ log(text2);
8043
+ return EMPTY("no-repo", null, text2);
8044
+ }
8045
+ const repoSlug = `${target.owner}/${target.name}`;
8046
+ const config2 = await Promise.resolve().then(() => doReadConfig(env)).catch(() => ({}));
8047
+ if (!config2.device_token) {
8048
+ const text2 = `backthread sweep: not logged in \u2014 skipping gap recovery for ${repoSlug}.`;
8049
+ log(text2);
8050
+ return EMPTY("no-auth", repoSlug, text2);
8051
+ }
8052
+ const state = await doReadState(env).catch(() => ({ processed: [], lastSweptAt: {} }));
8053
+ if (!input.force) {
8054
+ const last = state.lastSweptAt[repoSlug];
8055
+ if (last) {
8056
+ const age = Date.parse(now) - Date.parse(last);
8057
+ if (Number.isFinite(age) && age >= 0 && age < debounceMs) {
8058
+ return EMPTY("debounced", repoSlug, `backthread sweep: ${repoSlug} swept recently \u2014 skipped.`);
8059
+ }
8060
+ }
8061
+ }
8062
+ const mainRoot = doMainRoot(cwd) ?? cwd;
8063
+ const mainSlug = slugifyCwd(mainRoot);
8064
+ const projectsRoot = join6(home, ".claude", "projects");
8065
+ const entries = await doReadDir(projectsRoot);
8066
+ const candidates = entries.filter((n) => n === mainSlug || n.startsWith(mainSlug + "-")).sort();
8067
+ const skip = new Set(state.processed);
8068
+ for (const sid of input.knownCapturedSessionIds ?? []) if (sid) skip.add(sid);
8069
+ const attributions = [];
8070
+ const newlyProcessed = [];
8071
+ let dirsSwept = 0;
8072
+ let found = 0;
8073
+ let skipped = 0;
8074
+ let captured = 0;
8075
+ let decisions = 0;
8076
+ for (const dirName of candidates) {
8077
+ const dir = join6(projectsRoot, dirName);
8078
+ const files = (await doReadDir(dir)).filter((n) => n.endsWith(".jsonl")).sort();
8079
+ if (files.length === 0) continue;
8080
+ let embeddedCwd = null;
8081
+ for (const file2 of files) {
8082
+ embeddedCwd = extractCwdFromRaw(await doReadFile(join6(dir, file2)));
8083
+ if (embeddedCwd) break;
8084
+ }
8085
+ const cwdExists = embeddedCwd ? await doPathExists(embeddedCwd) : false;
8086
+ const resolved = embeddedCwd && cwdExists ? resolveRepo(embeddedCwd, readRemote) : null;
8087
+ const cls = classifyDir({ dirName, mainSlug, mainRoot, embeddedCwd, cwdExists, resolved, target });
8088
+ attributions.push({ dir: dirName, mode: cls.mode, cwd: cls.cwd, transcripts: files.length });
8089
+ if (!cls.include) {
8090
+ if (cls.mode === "unattributable") {
8091
+ log(
8092
+ `backthread sweep: ${files.length} transcript(s) in ${dirName} can't be attributed to ${repoSlug}` + (cls.cwd ? ` (cwd ${cls.cwd} is gone)` : " (no cwd recorded)") + " \u2014 left for GitHub-derived recovery (ARP-538)."
8093
+ );
8094
+ }
8095
+ continue;
8096
+ }
8097
+ if (cls.mode === "heuristic") {
8098
+ log(
8099
+ `backthread sweep: attributing ${files.length} transcript(s) in deleted-worktree dir ${dirName} to ${repoSlug} by path heuristic (cwd ${cls.cwd} no longer exists).`
8100
+ );
8101
+ }
8102
+ dirsSwept += 1;
8103
+ const captureDeps = {
8104
+ ...deps.captureDeps,
8105
+ readRemoteImpl: () => syntheticRemote(target),
8106
+ // A silent background sweep must NEVER pop a browser login.
8107
+ ensureAuthImpl: deps.captureDeps?.ensureAuthImpl ?? (() => {
8108
+ })
8109
+ };
8110
+ for (const file2 of files) {
8111
+ const sid = basename(file2, ".jsonl");
8112
+ if (skip.has(sid)) {
8113
+ skipped += 1;
8114
+ continue;
8115
+ }
8116
+ found += 1;
8117
+ let outcome;
8118
+ try {
8119
+ outcome = await run(
8120
+ {
8121
+ transcript_path: join6(dir, file2),
8122
+ cwd: cls.cwd ?? mainRoot,
8123
+ session_id: sid,
8124
+ hook_event_name: "SessionEnd"
8125
+ },
8126
+ captureDeps
8127
+ );
8128
+ } catch (e) {
8129
+ outcome = { status: "error", detail: `capture threw (swallowed): ${e.message}` };
8130
+ }
8131
+ if (outcome.status === "persisted" || outcome.status === "persisted-by-server") {
8132
+ captured += 1;
8133
+ decisions += typeof outcome.count === "number" ? outcome.count : 0;
8134
+ }
8135
+ if (isTerminallyProcessed(outcome)) {
8136
+ newlyProcessed.push(sid);
8137
+ skip.add(sid);
8138
+ }
8139
+ log(` ${dirName}/${file2}: ${outcome.status}${typeof outcome.count === "number" ? ` (${outcome.count})` : ""}`);
8140
+ }
8141
+ }
8142
+ const nextState = addProcessed(state, newlyProcessed);
8143
+ nextState.lastSweptAt = { ...nextState.lastSweptAt, [repoSlug]: now };
8144
+ await doWriteState(nextState, env).catch(() => {
8145
+ });
8146
+ const text = `backthread sweep: ${repoSlug} \u2014 swept ${dirsSwept} dir(s), recovered ${decisions} decision(s) from ${captured} session(s) (${found} processed, ${skipped} already captured).`;
8147
+ log(text);
8148
+ return {
8149
+ status: "swept",
8150
+ repo: repoSlug,
8151
+ dirsSwept,
8152
+ found,
8153
+ skipped,
8154
+ captured,
8155
+ decisions,
8156
+ attributions,
8157
+ text
8158
+ };
8159
+ } catch (e) {
8160
+ const text = `backthread sweep: failed (swallowed) \u2014 ${e.message}`;
8161
+ log(text);
8162
+ return EMPTY("error", null, text);
8163
+ }
8164
+ }
8165
+ async function runGapRecoverySweep(input = {}, deps = {}) {
8166
+ return runSweep({ ...input, force: input.force ?? false }, deps);
8167
+ }
8168
+
8169
+ // src/backfill.ts
8170
+ async function runBackfill(input = {}, deps = {}) {
8171
+ const summary = await runSweep({ cwd: input.cwd, force: true }, deps);
8172
+ return {
8173
+ found: summary.found,
8174
+ captured: summary.captured,
8175
+ decisions: summary.decisions,
8176
+ results: [],
8177
+ text: summary.text
8178
+ };
8179
+ }
8180
+
8181
+ // src/installAgent.ts
8182
+ import { execFile } from "node:child_process";
8183
+ import { promisify } from "node:util";
8184
+ import { homedir as homedir4 } from "node:os";
8185
+ import { join as join7, dirname as dirname3 } from "node:path";
8186
+ import { readFile as readFile5, writeFile as writeFile4, mkdir as mkdir4 } from "node:fs/promises";
8187
+ var execFileP = promisify(execFile);
8188
+ var MCP_COMMAND = "npx";
8189
+ var MCP_ARGS = ["-y", "backthread", "mcp"];
8190
+ function hookCommand(agent) {
8191
+ return `npx -y backthread capture --from-hook --agent ${agent} --detach`;
8192
+ }
8193
+ var MIN_VERSION = {
8194
+ codex: "0.124.0",
8195
+ cursor: "1.7.0",
8196
+ gemini: "0.26.0"
8197
+ };
8198
+ var VERSION_BIN = {
8199
+ codex: "codex",
8200
+ cursor: "cursor-agent",
8201
+ gemini: "gemini"
8202
+ };
8203
+ async function loadJsonObject(readFileImpl, path) {
8204
+ let raw;
8205
+ try {
8206
+ raw = await readFileImpl(path);
8207
+ } catch (e) {
8208
+ if (isNotFound2(e)) return {};
8209
+ throw e;
8210
+ }
8211
+ let parsed;
8212
+ try {
8213
+ parsed = JSON.parse(raw);
8214
+ } catch {
8215
+ throw new Error(`${path} exists but is not valid JSON \u2014 refusing to overwrite it. Fix it (or add the config manually) and re-run.`);
8216
+ }
8217
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
8218
+ throw new Error(`${path} is not a JSON object \u2014 refusing to overwrite it. Fix it (or add the config manually) and re-run.`);
8219
+ }
8220
+ return parsed;
8221
+ }
8222
+ function isNotFound2(err) {
8223
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
8224
+ }
8225
+ function asObject(v) {
8226
+ return v && typeof v === "object" && !Array.isArray(v) ? { ...v } : {};
8227
+ }
8228
+ function mcpServerEntry() {
8229
+ return { command: MCP_COMMAND, args: [...MCP_ARGS] };
8230
+ }
8231
+ function withMcpServer(settings) {
8232
+ const mcpServers = asObject(settings.mcpServers);
8233
+ const desired = mcpServerEntry();
8234
+ if (JSON.stringify(mcpServers.backthread) === JSON.stringify(desired)) {
8235
+ return { next: settings, changed: false };
8236
+ }
8237
+ mcpServers.backthread = desired;
8238
+ return { next: { ...settings, mcpServers }, changed: true };
8239
+ }
8240
+ function withNestedHook(settings, event, command, extra = {}) {
8241
+ const hooks = asObject(settings.hooks);
8242
+ const list = Array.isArray(hooks[event]) ? [...hooks[event]] : [];
8243
+ const present = list.some((g) => {
8244
+ const inner = g?.hooks;
8245
+ return Array.isArray(inner) && inner.some((h) => h?.command === command);
8246
+ });
8247
+ if (present) return { next: settings, changed: false };
8248
+ list.push({ hooks: [{ type: "command", command, ...extra }] });
8249
+ hooks[event] = list;
8250
+ return { next: { ...settings, hooks }, changed: true };
8251
+ }
8252
+ async function writeJson(deps, path, obj) {
8253
+ const doMkdir = deps.mkdirImpl ?? (async (d) => void await mkdir4(d, { recursive: true }));
8254
+ const doWrite = deps.writeFileImpl ?? ((p, d) => writeFile4(p, d));
8255
+ await doMkdir(dirname3(path));
8256
+ await doWrite(path, JSON.stringify(obj, null, 2) + "\n");
8257
+ }
8258
+ async function installGemini(home, deps) {
8259
+ const doRead = deps.readFileImpl ?? ((p) => readFile5(p, "utf8"));
8260
+ const path = join7(home, ".gemini", "settings.json");
8261
+ const current = await loadJsonObject(doRead, path);
8262
+ const a = withMcpServer(current);
8263
+ const b = withNestedHook(a.next, "SessionEnd", hookCommand("gemini-cli"), { name: "backthread-capture" });
8264
+ if (a.changed || b.changed) await writeJson(deps, path, b.next);
8265
+ return [{ path, wrote: a.changed || b.changed }];
8266
+ }
8267
+ async function installCodex(home, deps) {
8268
+ const doRead = deps.readFileImpl ?? ((p) => readFile5(p, "utf8"));
8269
+ const writes = [];
8270
+ const tomlPath = join7(home, ".codex", "config.toml");
8271
+ let toml = "";
8272
+ try {
8273
+ toml = await doRead(tomlPath);
8274
+ } catch (e) {
8275
+ if (!isNotFound2(e)) throw e;
7879
8276
  }
7880
- const text = `backthread backfill: processed ${files.length} session(s), captured ${decisions} decision(s) from ${captured} session(s). Your "How it works" log is no longer empty.`;
7881
- log(text);
7882
- return { found: files.length, captured, decisions, results, text };
8277
+ if (toml.includes("[mcp_servers.backthread]")) {
8278
+ writes.push({ path: tomlPath, wrote: false });
8279
+ } else {
8280
+ const block = `[mcp_servers.backthread]
8281
+ command = "${MCP_COMMAND}"
8282
+ args = [${MCP_ARGS.map((a) => `"${a}"`).join(", ")}]
8283
+ `;
8284
+ const sep = toml.length === 0 ? "" : toml.endsWith("\n") ? "\n" : "\n\n";
8285
+ const doMkdir = deps.mkdirImpl ?? (async (d) => void await mkdir4(d, { recursive: true }));
8286
+ const doWrite = deps.writeFileImpl ?? ((p, d) => writeFile4(p, d));
8287
+ await doMkdir(dirname3(tomlPath));
8288
+ await doWrite(tomlPath, toml + sep + block);
8289
+ writes.push({ path: tomlPath, wrote: true });
8290
+ }
8291
+ const hooksPath = join7(home, ".codex", "hooks.json");
8292
+ const current = await loadJsonObject(doRead, hooksPath);
8293
+ const h = withNestedHook(current, "Stop", hookCommand("codex"), { timeout: 60 });
8294
+ if (h.changed) await writeJson(deps, hooksPath, h.next);
8295
+ writes.push({ path: hooksPath, wrote: h.changed });
8296
+ return writes;
8297
+ }
8298
+ async function installCursor(home, deps) {
8299
+ const doRead = deps.readFileImpl ?? ((p) => readFile5(p, "utf8"));
8300
+ const writes = [];
8301
+ const mcpPath = join7(home, ".cursor", "mcp.json");
8302
+ const mcpCurrent = await loadJsonObject(doRead, mcpPath);
8303
+ const m = withMcpServer(mcpCurrent);
8304
+ if (m.changed) await writeJson(deps, mcpPath, m.next);
8305
+ writes.push({ path: mcpPath, wrote: m.changed });
8306
+ const hooksPath = join7(home, ".cursor", "hooks.json");
8307
+ const hooksCurrent = await loadJsonObject(doRead, hooksPath);
8308
+ const c = withCursorStopHook(hooksCurrent);
8309
+ if (c.changed) await writeJson(deps, hooksPath, c.next);
8310
+ writes.push({ path: hooksPath, wrote: c.changed });
8311
+ return writes;
8312
+ }
8313
+ function withCursorStopHook(settings) {
8314
+ const command = hookCommand("cursor");
8315
+ const hooks = asObject(settings.hooks);
8316
+ const stop = Array.isArray(hooks.stop) ? [...hooks.stop] : [];
8317
+ const present = stop.some((h) => h?.command === command);
8318
+ const hasVersion = typeof settings.version === "number";
8319
+ if (present && hasVersion) return { next: settings, changed: false };
8320
+ if (!present) stop.push({ command });
8321
+ hooks.stop = stop;
8322
+ return { next: { ...settings, version: hasVersion ? settings.version : 1, hooks }, changed: true };
8323
+ }
8324
+ function cursorDeeplink() {
8325
+ const config2 = Buffer.from(JSON.stringify(mcpServerEntry())).toString("base64");
8326
+ return `cursor://anysphere.cursor-deeplink/mcp/install?name=backthread&config=${config2}`;
8327
+ }
8328
+ function parseSemver(s) {
8329
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(s);
8330
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
8331
+ }
8332
+ function isBelow(a, b) {
8333
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i];
8334
+ return false;
8335
+ }
8336
+ async function probeVersion(agent) {
8337
+ try {
8338
+ const { stdout } = await execFileP(VERSION_BIN[agent], ["--version"], { timeout: 3e3 });
8339
+ return stdout?.trim() || null;
8340
+ } catch {
8341
+ return null;
8342
+ }
8343
+ }
8344
+ async function versionGate(agent, deps) {
8345
+ const probe = deps.probeVersionImpl ?? probeVersion;
8346
+ const raw = await probe(agent).catch(() => null);
8347
+ if (!raw) return null;
8348
+ const got = parseSemver(raw);
8349
+ const min = parseSemver(MIN_VERSION[agent]);
8350
+ if (got && isBelow(got, min)) {
8351
+ return `Detected ${agent} ${got.join(".")}, but the capture hook needs ${MIN_VERSION[agent]}+. The MCP query tool works now; upgrade ${agent} for auto-capture.`;
8352
+ }
8353
+ return null;
8354
+ }
8355
+ async function runInstallAgent(agent, deps = {}) {
8356
+ const home = deps.home ?? homedir4();
8357
+ const versionWarning = await versionGate(agent, deps);
8358
+ let writes;
8359
+ switch (agent) {
8360
+ case "gemini":
8361
+ writes = await installGemini(home, deps);
8362
+ break;
8363
+ case "codex":
8364
+ writes = await installCodex(home, deps);
8365
+ break;
8366
+ case "cursor":
8367
+ writes = await installCursor(home, deps);
8368
+ break;
8369
+ }
8370
+ return { agent, writes, versionWarning, deeplink: agent === "cursor" ? cursorDeeplink() : null };
8371
+ }
8372
+ function parseInstallAgent(value) {
8373
+ if (!value) return null;
8374
+ const v = value.trim().toLowerCase();
8375
+ if (v === "claude-code" || v === "claude" || v === "cc") return "claude-code";
8376
+ if (v === "gemini" || v === "gemini-cli") return "gemini";
8377
+ if (v === "codex" || v === "cursor") return v;
8378
+ return null;
7883
8379
  }
7884
8380
 
7885
8381
  // src/install.ts
@@ -7892,19 +8388,22 @@ var TRUST_COPY = [
7892
8388
  " code blocks replaced with [code redacted] \u2014 to Backthread's Worker, never source.",
7893
8389
  " Full details: https://app.backthread.dev/security"
7894
8390
  ].join("\n");
7895
- var HOOK_COMMAND = "npx backthread capture";
7896
- async function registerHook(cwd, deps = {}) {
7897
- const doReadFile = deps.readFileImpl ?? ((p) => readFile3(p, "utf8"));
7898
- const doWriteFile = deps.writeFileImpl ?? ((p, d) => writeFile3(p, d));
7899
- const doMkdir = deps.mkdirImpl ?? (async (d) => void await mkdir3(d, { recursive: true }));
7900
- const settingsDir = join6(cwd, ".claude");
7901
- const settingsPath = join6(settingsDir, "settings.json");
8391
+ var HOOK_COMMAND = "npx backthread capture --from-hook --agent claude-code --detach";
8392
+ var LEGACY_HOOK_COMMANDS = ["npx backthread capture"];
8393
+ var OUR_HOOK_COMMANDS = /* @__PURE__ */ new Set([HOOK_COMMAND, ...LEGACY_HOOK_COMMANDS]);
8394
+ async function registerHook(deps = {}) {
8395
+ const doReadFile = deps.readFileImpl ?? ((p) => readFile6(p, "utf8"));
8396
+ const doWriteFile = deps.writeFileImpl ?? ((p, d) => writeFile5(p, d));
8397
+ const doMkdir = deps.mkdirImpl ?? (async (d) => void await mkdir5(d, { recursive: true }));
8398
+ const home = deps.home ?? homedir5();
8399
+ const settingsDir = join8(home, ".claude");
8400
+ const settingsPath = join8(settingsDir, "settings.json");
7902
8401
  let settings = {};
7903
8402
  let raw = null;
7904
8403
  try {
7905
8404
  raw = await doReadFile(settingsPath);
7906
8405
  } catch (e) {
7907
- if (!isNotFound2(e)) throw e;
8406
+ if (!isNotFound3(e)) throw e;
7908
8407
  raw = null;
7909
8408
  }
7910
8409
  if (raw !== null) {
@@ -7932,27 +8431,49 @@ async function registerHook(cwd, deps = {}) {
7932
8431
  await doWriteFile(settingsPath, JSON.stringify(merged, null, 2) + "\n");
7933
8432
  return { wrote: true, path: settingsPath };
7934
8433
  }
7935
- function isNotFound2(err) {
8434
+ function isNotFound3(err) {
7936
8435
  return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
7937
8436
  }
7938
8437
  function mergeSessionEndHook(settings) {
7939
8438
  const hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? { ...settings.hooks } : {};
7940
8439
  const sessionEnd = Array.isArray(hooks.SessionEnd) ? [...hooks.SessionEnd] : [];
7941
- if (sessionEnd.some(groupHasOurCommand)) return null;
7942
- sessionEnd.push({
7943
- hooks: [{ type: "command", command: HOOK_COMMAND }]
8440
+ if (sessionEnd.some((g) => groupHasCommand(g, HOOK_COMMAND))) return null;
8441
+ let migrated = false;
8442
+ const nextSessionEnd = sessionEnd.map((group) => {
8443
+ const rewritten = rewriteLegacyCommand(group);
8444
+ if (rewritten !== group) migrated = true;
8445
+ return rewritten;
7944
8446
  });
7945
- hooks.SessionEnd = sessionEnd;
8447
+ if (!migrated) {
8448
+ nextSessionEnd.push({ hooks: [{ type: "command", command: HOOK_COMMAND }] });
8449
+ }
8450
+ hooks.SessionEnd = nextSessionEnd;
7946
8451
  return { ...settings, hooks };
7947
8452
  }
7948
- function groupHasOurCommand(group) {
8453
+ function groupHasCommand(group, command) {
7949
8454
  if (!group || typeof group !== "object") return false;
7950
8455
  const inner = group.hooks;
7951
8456
  if (!Array.isArray(inner)) return false;
7952
8457
  return inner.some(
7953
- (h) => h && typeof h === "object" && h.command === HOOK_COMMAND
8458
+ (h) => h && typeof h === "object" && h.command === command
7954
8459
  );
7955
8460
  }
8461
+ function rewriteLegacyCommand(group) {
8462
+ if (!group || typeof group !== "object") return group;
8463
+ const inner = group.hooks;
8464
+ if (!Array.isArray(inner)) return group;
8465
+ let changed = false;
8466
+ const nextInner = inner.map((h) => {
8467
+ if (!h || typeof h !== "object") return h;
8468
+ const cmd = h.command;
8469
+ if (typeof cmd === "string" && cmd !== HOOK_COMMAND && OUR_HOOK_COMMANDS.has(cmd)) {
8470
+ changed = true;
8471
+ return { ...h, command: HOOK_COMMAND };
8472
+ }
8473
+ return h;
8474
+ });
8475
+ return changed ? { ...group, hooks: nextInner } : group;
8476
+ }
7956
8477
  async function runInstall(opts = {}, deps = {}) {
7957
8478
  const env = opts.env ?? process.env;
7958
8479
  const log = opts.log ?? ((m) => console.error(m));
@@ -7977,12 +8498,36 @@ async function runInstall(opts = {}, deps = {}) {
7977
8498
  log(" Run `backthread login` to authorize, then re-run `backthread install`.");
7978
8499
  }
7979
8500
  }
8501
+ const targetAgent = opts.agent && opts.agent !== "claude-code" ? opts.agent : null;
8502
+ if (targetAgent) {
8503
+ const doAgent = deps.runInstallAgentImpl ?? runInstallAgent;
8504
+ let agentResult = null;
8505
+ try {
8506
+ agentResult = await doAgent(targetAgent, deps.agentDeps);
8507
+ if (agentResult.versionWarning) log(` \u26A0 ${agentResult.versionWarning}`);
8508
+ for (const w of agentResult.writes) {
8509
+ log(
8510
+ w.wrote ? `[2/2] ${targetAgent}: configured ${w.path}.` : `[2/2] ${targetAgent}: already configured in ${w.path} (no change).`
8511
+ );
8512
+ }
8513
+ if (agentResult.deeplink) log(` One-click MCP install: ${agentResult.deeplink}`);
8514
+ } catch (e) {
8515
+ log(`[2/2] ${targetAgent}: not configured \u2014 ${e.message}`);
8516
+ log(" You can add the MCP server + hook manually (see the README).");
8517
+ }
8518
+ log(
8519
+ `
8520
+ Backthread is set up for ${targetAgent}. New sessions are captured automatically.` + (authed ? "" : " Run `backthread login` to finish authorizing.")
8521
+ );
8522
+ const exitCode2 = !authed && !opts.skipAuth ? 1 : 0;
8523
+ return { exitCode: exitCode2, authed, hookRegistered: agentResult !== null, backfill: null, agentResult };
8524
+ }
7980
8525
  let hookRegistered = false;
7981
8526
  if (opts.skipHook) {
7982
8527
  log("[2/3] Hook: skipped (registered by the plugin manifest).");
7983
8528
  } else {
7984
8529
  try {
7985
- const { wrote, path } = await registerHook(cwd, deps);
8530
+ const { wrote, path } = await registerHook(deps);
7986
8531
  hookRegistered = true;
7987
8532
  log(
7988
8533
  wrote ? `[2/3] Hook: SessionEnd capture hook added to ${path}.` : `[2/3] Hook: SessionEnd capture hook already present in ${path} (no change).`
@@ -8007,7 +8552,7 @@ async function runInstall(opts = {}, deps = {}) {
8007
8552
  }
8008
8553
  log("\nBackthread is set up. New sessions are captured automatically when they end.");
8009
8554
  const exitCode = !authed && !opts.skipAuth ? 1 : 0;
8010
- return { exitCode, authed, hookRegistered, backfill };
8555
+ return { exitCode, authed, hookRegistered, backfill, agentResult: null };
8011
8556
  }
8012
8557
 
8013
8558
  // src/onboardingState.ts
@@ -8131,7 +8676,7 @@ function normalizeState(raw) {
8131
8676
 
8132
8677
  // src/firstRun.ts
8133
8678
  function firstRunStatePath(env = process.env) {
8134
- return join7(configDir(env), "first-run.json");
8679
+ return join9(configDir(env), "first-run.json");
8135
8680
  }
8136
8681
  function parseFirstRunState(raw) {
8137
8682
  try {
@@ -8150,7 +8695,7 @@ function parseFirstRunState(raw) {
8150
8695
  }
8151
8696
  async function readFirstRunState(env = process.env) {
8152
8697
  try {
8153
- return parseFirstRunState(await readFile4(firstRunStatePath(env), "utf8"));
8698
+ return parseFirstRunState(await readFile7(firstRunStatePath(env), "utf8"));
8154
8699
  } catch {
8155
8700
  return {};
8156
8701
  }
@@ -8160,12 +8705,12 @@ async function updateFirstRunState(patch, env = process.env) {
8160
8705
  const current = await readFirstRunState(env);
8161
8706
  const next = { ...current, ...patch };
8162
8707
  const dir = configDir(env);
8163
- await mkdir4(dir, { recursive: true, mode: DIR_MODE });
8164
- await chmod3(dir, DIR_MODE).catch(() => {
8708
+ await mkdir6(dir, { recursive: true, mode: DIR_MODE });
8709
+ await chmod4(dir, DIR_MODE).catch(() => {
8165
8710
  });
8166
8711
  const path = firstRunStatePath(env);
8167
- await writeFile4(path, JSON.stringify(next) + "\n", { mode: CONFIG_MODE });
8168
- await chmod3(path, CONFIG_MODE).catch(() => {
8712
+ await writeFile6(path, JSON.stringify(next) + "\n", { mode: CONFIG_MODE });
8713
+ await chmod4(path, CONFIG_MODE).catch(() => {
8169
8714
  });
8170
8715
  } catch {
8171
8716
  }
@@ -8317,7 +8862,7 @@ function readStream(stream) {
8317
8862
  async function runCapture(input, deps = {}) {
8318
8863
  const env = deps.env ?? process.env;
8319
8864
  const log = deps.log ?? ((m) => console.error(m));
8320
- const doReadFile = deps.readFileImpl ?? ((p) => readFile5(p, "utf8"));
8865
+ const doReadFile = deps.readFileImpl ?? ((p) => readFile8(p, "utf8"));
8321
8866
  const doReadConfig = deps.readConfigImpl ?? readConfig;
8322
8867
  const fireEnsureAuth = deps.ensureAuthImpl ?? ((e) => {
8323
8868
  void ensureAuth({ env: e }).catch(() => {
@@ -8472,8 +9017,8 @@ async function persistDerived(decisions, repo, config2, decidedAt, ctx) {
8472
9017
 
8473
9018
  // src/fromHook.ts
8474
9019
  import { spawn as spawn2 } from "node:child_process";
8475
- import { join as join8 } from "node:path";
8476
- import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir5, chmod as chmod4 } from "node:fs/promises";
9020
+ import { join as join10 } from "node:path";
9021
+ import { readFile as readFile9, writeFile as writeFile7, mkdir as mkdir7, chmod as chmod5 } from "node:fs/promises";
8477
9022
  var KNOWN_AGENTS = /* @__PURE__ */ new Set([
8478
9023
  "claude-code",
8479
9024
  "codex",
@@ -8514,7 +9059,7 @@ function normalizeHookInput(payload, _agent) {
8514
9059
  return out;
8515
9060
  }
8516
9061
  function captureStatePath(env = process.env) {
8517
- return join8(configDir(env), "capture-sessions.json");
9062
+ return join10(configDir(env), "capture-sessions.json");
8518
9063
  }
8519
9064
  var MAX_REMEMBERED2 = 200;
8520
9065
  function parseState2(raw) {
@@ -8530,7 +9075,7 @@ function parseState2(raw) {
8530
9075
  }
8531
9076
  async function readState2(env) {
8532
9077
  try {
8533
- return parseState2(await readFile6(captureStatePath(env), "utf8"));
9078
+ return parseState2(await readFile9(captureStatePath(env), "utf8"));
8534
9079
  } catch {
8535
9080
  return { captured: [] };
8536
9081
  }
@@ -8538,12 +9083,12 @@ async function readState2(env) {
8538
9083
  async function writeState2(state, env) {
8539
9084
  try {
8540
9085
  const dir = configDir(env);
8541
- await mkdir5(dir, { recursive: true, mode: DIR_MODE });
8542
- await chmod4(dir, DIR_MODE).catch(() => {
9086
+ await mkdir7(dir, { recursive: true, mode: DIR_MODE });
9087
+ await chmod5(dir, DIR_MODE).catch(() => {
8543
9088
  });
8544
9089
  const path = captureStatePath(env);
8545
- await writeFile5(path, JSON.stringify(state) + "\n", { mode: CONFIG_MODE });
8546
- await chmod4(path, CONFIG_MODE).catch(() => {
9090
+ await writeFile7(path, JSON.stringify(state) + "\n", { mode: CONFIG_MODE });
9091
+ await chmod5(path, CONFIG_MODE).catch(() => {
8547
9092
  });
8548
9093
  } catch {
8549
9094
  }
@@ -8632,6 +9177,15 @@ async function runFromHook(deps = {}) {
8632
9177
  const mark = deps.markCapturedImpl ?? markSessionCaptured;
8633
9178
  await mark(input.session_id, env).catch(() => {
8634
9179
  });
9180
+ if (isTerminallyProcessed(outcome)) {
9181
+ await (deps.markSweepProcessedImpl ?? markSweepProcessed)(input.session_id, env).catch(() => {
9182
+ });
9183
+ }
9184
+ }
9185
+ if (agent === "claude-code") {
9186
+ const sweep = deps.runSweepImpl ?? runGapRecoverySweep;
9187
+ await sweep({ cwd: input.cwd }, { env, captureDeps: deps.captureDeps }).catch(() => {
9188
+ });
8635
9189
  }
8636
9190
  return {
8637
9191
  exitCode: 0,
@@ -32934,7 +33488,9 @@ function formatQueryOutcome(outcome, question) {
32934
33488
  function buildMcpServer(deps = {}) {
32935
33489
  const server = new McpServer({
32936
33490
  name: deps.name ?? "backthread",
32937
- version: deps.version ?? "0.0.0"
33491
+ // Report the package's real version (read from package.json, ARP-478) instead of a
33492
+ // pinned 0.0.0, so an MCP host's serverInfo shows the installed Backthread version.
33493
+ version: deps.version ?? cliVersion()
32938
33494
  });
32939
33495
  server.registerTool(
32940
33496
  "capture",
@@ -32994,6 +33550,9 @@ Usage:
32994
33550
  backthread mcp Start the MCP server (capture + query tools) over stdio
32995
33551
  backthread install Set up capture for this repo (login + hook + backfill history)
32996
33552
  [--claim <code>] [--skip-auth] [--skip-hook] [--skip-backfill]
33553
+ backthread install --agent <codex|cursor|gemini>
33554
+ Set up capture for another agent: write its USER-GLOBAL
33555
+ MCP server config + session-end capture hook (idempotent)
32997
33556
  backthread help Show this message
32998
33557
 
32999
33558
  Docs: https://app.backthread.dev`;
@@ -33077,8 +33636,15 @@ async function main(argv) {
33077
33636
  return result.exitCode;
33078
33637
  }
33079
33638
  case "install": {
33639
+ const agentFlag = flagValue(rest, "--agent");
33640
+ const agent = parseInstallAgent(agentFlag);
33641
+ if (agentFlag !== void 0 && agent === null) {
33642
+ console.error(`Unknown --agent "${agentFlag}". Use one of: codex, cursor, gemini, claude-code.`);
33643
+ return 1;
33644
+ }
33080
33645
  const result = await runInstall({
33081
33646
  claim: parseClaimFlag(rest),
33647
+ agent: agent ?? void 0,
33082
33648
  skipAuth: rest.includes("--skip-auth"),
33083
33649
  skipHook: rest.includes("--skip-hook"),
33084
33650
  skipBackfill: rest.includes("--skip-backfill")
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 AT USER/GLOBAL SCOPE — no mutation of the user's project .claude/settings.json, and it fires across EVERY repo AND git worktree (ARP-680: a per-project, gitignored .claude/settings.json hook is exactly what froze the dogfood log — worktree sessions never carried it; an installed plugin's hooks follow the user, not the cwd). The command invokes the plugin's BUNDLED, self-contained bin via ${CLAUDE_PLUGIN_ROOT} (ARP-474's dist-bundle/backthread.js — committed to git so the marketplace-cloned plugin has it; CC runs no build step on install), NOT `npx backthread`, which would resolve a possibly-stale or absent global / npm-linked install (the second half of the ARP-680 dogfood freeze). The detached worker re-spawns via process.execPath + process.argv[1], so pointing argv[1] at the bundle propagates the same self-contained bin through the WHOLE chain (hook → detached worker). It 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 USER-SCOPE ~/.claude/settings.json fallback that `backthread install` writes for the bare-npx (non-plugin) path (ARP-503). 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": "node \"${CLAUDE_PLUGIN_ROOT}/dist-bundle/backthread.js\" 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.3",
3
+ "version": "0.2.0",
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",