backthread 0.1.4 → 0.2.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.
@@ -1,11 +1,17 @@
1
1
  {
2
2
  "name": "backthread",
3
3
  "displayName": "Backthread",
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",
4
+ "description": "Backthread helps you understand your codebase while AI ships features. It captures the why behind every Claude Code session so you can ask \"how does X work?\" without digging through PRs.",
5
+ "version": "0.2.1",
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]";
@@ -7717,17 +7717,13 @@ async function maybeNudge(status, repo, sessionId, deps = {}) {
7717
7717
  }
7718
7718
 
7719
7719
  // src/firstRun.ts
7720
- import { join as join7 } from "node:path";
7721
- 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";
7722
7722
 
7723
7723
  // src/install.ts
7724
- import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir3 } from "node:fs/promises";
7725
- import { join as join6 } from "node:path";
7726
-
7727
- // src/backfill.ts
7728
- import { readdir } from "node:fs/promises";
7729
- import { homedir as homedir3 } from "node:os";
7730
- 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";
7731
7727
 
7732
7728
  // src/captureCommand.ts
7733
7729
  import { stat } from "node:fs/promises";
@@ -7837,9 +7833,135 @@ function parseManualArgs(argv) {
7837
7833
  return { manual, input };
7838
7834
  }
7839
7835
 
7840
- // src/backfill.ts
7841
- function claudeProjectsDir(cwd, home) {
7842
- 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 };
7843
7965
  }
7844
7966
  async function defaultReadDir(dir) {
7845
7967
  try {
@@ -7848,53 +7970,412 @@ async function defaultReadDir(dir) {
7848
7970
  return [];
7849
7971
  }
7850
7972
  }
7851
- async function runBackfill(input = {}, deps = {}) {
7852
- const log = deps.log ?? ((m) => console.error(m));
7853
- const home = (deps.homedirImpl ?? homedir3)();
7854
- const cwd = input.cwd ?? process.cwd();
7855
- const doReadDir = deps.readDirImpl ?? defaultReadDir;
7856
- const run = deps.runCaptureImpl ?? runCapture;
7857
- const dir = claudeProjectsDir(cwd, home);
7858
- let entries;
7973
+ async function defaultPathExists(path) {
7859
7974
  try {
7860
- entries = await doReadDir(dir);
7975
+ await stat2(path);
7976
+ return true;
7861
7977
  } catch {
7862
- entries = [];
7863
- }
7864
- const files = entries.filter((n) => n.endsWith(".jsonl")).sort();
7865
- if (files.length === 0) {
7866
- 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.";
7867
- log(text2);
7868
- return { found: 0, captured: 0, decisions: 0, results: [], text: text2 };
7978
+ return false;
7869
7979
  }
7870
- log(
7871
- `backthread backfill: found ${files.length} past Claude Code session(s) for this repo \u2014 seeding your decision log (best-effort, this never blocks)\u2026`
7872
- );
7873
- const results = [];
7874
- let captured = 0;
7875
- let decisions = 0;
7876
- for (const file2 of files) {
7877
- const hookInput = {
7878
- transcript_path: join5(dir, file2),
7980
+ }
7981
+ function defaultMainRoot(cwd) {
7982
+ try {
7983
+ const out = execFileSync2("git", ["rev-parse", "--git-common-dir"], {
7879
7984
  cwd,
7880
- hook_event_name: "SessionEnd"
7881
- };
7882
- 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) => {
7883
8001
  try {
7884
- outcome = await run(hookInput, deps.captureDeps);
7885
- } catch (e) {
7886
- outcome = { status: "error", detail: `capture threw (swallowed): ${e.message}` };
8002
+ return await baseReadDir(d);
8003
+ } catch {
8004
+ return [];
7887
8005
  }
7888
- results.push({ file: file2, outcome });
7889
- if (outcome.status === "persisted" || outcome.status === "persisted-by-server") {
7890
- captured += 1;
7891
- decisions += typeof outcome.count === "number" ? outcome.count : 0;
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 "";
7892
8013
  }
7893
- log(` ${file2}: ${outcome.status}${typeof outcome.count === "number" ? ` (${outcome.count})` : ""}`);
8014
+ };
8015
+ const basePathExists = deps.pathExistsImpl ?? defaultPathExists;
8016
+ const doPathExists = async (p) => {
8017
+ try {
8018
+ return await basePathExists(p);
8019
+ } catch {
8020
+ return false;
8021
+ }
8022
+ };
8023
+ const doMainRoot = (c) => {
8024
+ try {
8025
+ return (deps.mainRootImpl ?? defaultMainRoot)(c);
8026
+ } catch {
8027
+ return null;
8028
+ }
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);
7894
8163
  }
7895
- 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.`;
7896
- log(text);
7897
- return { found: files.length, captured, decisions, results, text };
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;
8276
+ }
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;
7898
8379
  }
7899
8380
 
7900
8381
  // src/install.ts
@@ -7910,18 +8391,19 @@ var TRUST_COPY = [
7910
8391
  var HOOK_COMMAND = "npx backthread capture --from-hook --agent claude-code --detach";
7911
8392
  var LEGACY_HOOK_COMMANDS = ["npx backthread capture"];
7912
8393
  var OUR_HOOK_COMMANDS = /* @__PURE__ */ new Set([HOOK_COMMAND, ...LEGACY_HOOK_COMMANDS]);
7913
- async function registerHook(cwd, deps = {}) {
7914
- const doReadFile = deps.readFileImpl ?? ((p) => readFile3(p, "utf8"));
7915
- const doWriteFile = deps.writeFileImpl ?? ((p, d) => writeFile3(p, d));
7916
- const doMkdir = deps.mkdirImpl ?? (async (d) => void await mkdir3(d, { recursive: true }));
7917
- const settingsDir = join6(cwd, ".claude");
7918
- const settingsPath = join6(settingsDir, "settings.json");
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");
7919
8401
  let settings = {};
7920
8402
  let raw = null;
7921
8403
  try {
7922
8404
  raw = await doReadFile(settingsPath);
7923
8405
  } catch (e) {
7924
- if (!isNotFound2(e)) throw e;
8406
+ if (!isNotFound3(e)) throw e;
7925
8407
  raw = null;
7926
8408
  }
7927
8409
  if (raw !== null) {
@@ -7949,7 +8431,7 @@ async function registerHook(cwd, deps = {}) {
7949
8431
  await doWriteFile(settingsPath, JSON.stringify(merged, null, 2) + "\n");
7950
8432
  return { wrote: true, path: settingsPath };
7951
8433
  }
7952
- function isNotFound2(err) {
8434
+ function isNotFound3(err) {
7953
8435
  return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
7954
8436
  }
7955
8437
  function mergeSessionEndHook(settings) {
@@ -8016,12 +8498,36 @@ async function runInstall(opts = {}, deps = {}) {
8016
8498
  log(" Run `backthread login` to authorize, then re-run `backthread install`.");
8017
8499
  }
8018
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
+ }
8019
8525
  let hookRegistered = false;
8020
8526
  if (opts.skipHook) {
8021
8527
  log("[2/3] Hook: skipped (registered by the plugin manifest).");
8022
8528
  } else {
8023
8529
  try {
8024
- const { wrote, path } = await registerHook(cwd, deps);
8530
+ const { wrote, path } = await registerHook(deps);
8025
8531
  hookRegistered = true;
8026
8532
  log(
8027
8533
  wrote ? `[2/3] Hook: SessionEnd capture hook added to ${path}.` : `[2/3] Hook: SessionEnd capture hook already present in ${path} (no change).`
@@ -8046,7 +8552,7 @@ async function runInstall(opts = {}, deps = {}) {
8046
8552
  }
8047
8553
  log("\nBackthread is set up. New sessions are captured automatically when they end.");
8048
8554
  const exitCode = !authed && !opts.skipAuth ? 1 : 0;
8049
- return { exitCode, authed, hookRegistered, backfill };
8555
+ return { exitCode, authed, hookRegistered, backfill, agentResult: null };
8050
8556
  }
8051
8557
 
8052
8558
  // src/onboardingState.ts
@@ -8170,7 +8676,7 @@ function normalizeState(raw) {
8170
8676
 
8171
8677
  // src/firstRun.ts
8172
8678
  function firstRunStatePath(env = process.env) {
8173
- return join7(configDir(env), "first-run.json");
8679
+ return join9(configDir(env), "first-run.json");
8174
8680
  }
8175
8681
  function parseFirstRunState(raw) {
8176
8682
  try {
@@ -8189,7 +8695,7 @@ function parseFirstRunState(raw) {
8189
8695
  }
8190
8696
  async function readFirstRunState(env = process.env) {
8191
8697
  try {
8192
- return parseFirstRunState(await readFile4(firstRunStatePath(env), "utf8"));
8698
+ return parseFirstRunState(await readFile7(firstRunStatePath(env), "utf8"));
8193
8699
  } catch {
8194
8700
  return {};
8195
8701
  }
@@ -8199,12 +8705,12 @@ async function updateFirstRunState(patch, env = process.env) {
8199
8705
  const current = await readFirstRunState(env);
8200
8706
  const next = { ...current, ...patch };
8201
8707
  const dir = configDir(env);
8202
- await mkdir4(dir, { recursive: true, mode: DIR_MODE });
8203
- await chmod3(dir, DIR_MODE).catch(() => {
8708
+ await mkdir6(dir, { recursive: true, mode: DIR_MODE });
8709
+ await chmod4(dir, DIR_MODE).catch(() => {
8204
8710
  });
8205
8711
  const path = firstRunStatePath(env);
8206
- await writeFile4(path, JSON.stringify(next) + "\n", { mode: CONFIG_MODE });
8207
- await chmod3(path, CONFIG_MODE).catch(() => {
8712
+ await writeFile6(path, JSON.stringify(next) + "\n", { mode: CONFIG_MODE });
8713
+ await chmod4(path, CONFIG_MODE).catch(() => {
8208
8714
  });
8209
8715
  } catch {
8210
8716
  }
@@ -8356,7 +8862,7 @@ function readStream(stream) {
8356
8862
  async function runCapture(input, deps = {}) {
8357
8863
  const env = deps.env ?? process.env;
8358
8864
  const log = deps.log ?? ((m) => console.error(m));
8359
- const doReadFile = deps.readFileImpl ?? ((p) => readFile5(p, "utf8"));
8865
+ const doReadFile = deps.readFileImpl ?? ((p) => readFile8(p, "utf8"));
8360
8866
  const doReadConfig = deps.readConfigImpl ?? readConfig;
8361
8867
  const fireEnsureAuth = deps.ensureAuthImpl ?? ((e) => {
8362
8868
  void ensureAuth({ env: e }).catch(() => {
@@ -8511,8 +9017,8 @@ async function persistDerived(decisions, repo, config2, decidedAt, ctx) {
8511
9017
 
8512
9018
  // src/fromHook.ts
8513
9019
  import { spawn as spawn2 } from "node:child_process";
8514
- import { join as join8 } from "node:path";
8515
- 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";
8516
9022
  var KNOWN_AGENTS = /* @__PURE__ */ new Set([
8517
9023
  "claude-code",
8518
9024
  "codex",
@@ -8553,7 +9059,7 @@ function normalizeHookInput(payload, _agent) {
8553
9059
  return out;
8554
9060
  }
8555
9061
  function captureStatePath(env = process.env) {
8556
- return join8(configDir(env), "capture-sessions.json");
9062
+ return join10(configDir(env), "capture-sessions.json");
8557
9063
  }
8558
9064
  var MAX_REMEMBERED2 = 200;
8559
9065
  function parseState2(raw) {
@@ -8569,7 +9075,7 @@ function parseState2(raw) {
8569
9075
  }
8570
9076
  async function readState2(env) {
8571
9077
  try {
8572
- return parseState2(await readFile6(captureStatePath(env), "utf8"));
9078
+ return parseState2(await readFile9(captureStatePath(env), "utf8"));
8573
9079
  } catch {
8574
9080
  return { captured: [] };
8575
9081
  }
@@ -8577,12 +9083,12 @@ async function readState2(env) {
8577
9083
  async function writeState2(state, env) {
8578
9084
  try {
8579
9085
  const dir = configDir(env);
8580
- await mkdir5(dir, { recursive: true, mode: DIR_MODE });
8581
- await chmod4(dir, DIR_MODE).catch(() => {
9086
+ await mkdir7(dir, { recursive: true, mode: DIR_MODE });
9087
+ await chmod5(dir, DIR_MODE).catch(() => {
8582
9088
  });
8583
9089
  const path = captureStatePath(env);
8584
- await writeFile5(path, JSON.stringify(state) + "\n", { mode: CONFIG_MODE });
8585
- await chmod4(path, CONFIG_MODE).catch(() => {
9090
+ await writeFile7(path, JSON.stringify(state) + "\n", { mode: CONFIG_MODE });
9091
+ await chmod5(path, CONFIG_MODE).catch(() => {
8586
9092
  });
8587
9093
  } catch {
8588
9094
  }
@@ -8671,6 +9177,15 @@ async function runFromHook(deps = {}) {
8671
9177
  const mark = deps.markCapturedImpl ?? markSessionCaptured;
8672
9178
  await mark(input.session_id, env).catch(() => {
8673
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
+ });
8674
9189
  }
8675
9190
  return {
8676
9191
  exitCode: 0,
@@ -32973,7 +33488,9 @@ function formatQueryOutcome(outcome, question) {
32973
33488
  function buildMcpServer(deps = {}) {
32974
33489
  const server = new McpServer({
32975
33490
  name: deps.name ?? "backthread",
32976
- 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()
32977
33494
  });
32978
33495
  server.registerTool(
32979
33496
  "capture",
@@ -33033,6 +33550,9 @@ Usage:
33033
33550
  backthread mcp Start the MCP server (capture + query tools) over stdio
33034
33551
  backthread install Set up capture for this repo (login + hook + backfill history)
33035
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)
33036
33556
  backthread help Show this message
33037
33557
 
33038
33558
  Docs: https://app.backthread.dev`;
@@ -33116,8 +33636,15 @@ async function main(argv) {
33116
33636
  return result.exitCode;
33117
33637
  }
33118
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
+ }
33119
33645
  const result = await runInstall({
33120
33646
  claim: parseClaimFlag(rest),
33647
+ agent: agent ?? void 0,
33121
33648
  skipAuth: rest.includes("--skip-auth"),
33122
33649
  skipHook: rest.includes("--skip-hook"),
33123
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. 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.",
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 --from-hook --agent claude-code --detach"
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,7 +1,7 @@
1
1
  {
2
2
  "name": "backthread",
3
- "version": "0.1.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.",
3
+ "version": "0.2.1",
4
+ "description": "Backthread helps you understand your codebase while AI ships features. The CLI captures the why behind every AI session and lets you ask how your codebase works, right from the terminal.",
5
5
  "license": "MIT",
6
6
  "author": "Backthread",
7
7
  "homepage": "https://backthread.dev",