backthread 0.1.4 → 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.
- package/.claude-plugin/plugin.json +8 -2
- package/README.md +35 -0
- package/commands/capture.md +1 -1
- package/commands/start.md +1 -1
- package/dist-bundle/backthread.js +605 -78
- package/hooks/hooks.json +2 -2
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
package/commands/capture.md
CHANGED
|
@@ -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
|
|
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
|
|
7721
|
-
import { readFile as
|
|
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
|
|
7725
|
-
import {
|
|
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/
|
|
7841
|
-
|
|
7842
|
-
|
|
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
|
|
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
|
-
|
|
7975
|
+
await stat2(path);
|
|
7976
|
+
return true;
|
|
7861
7977
|
} catch {
|
|
7862
|
-
|
|
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
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
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
|
-
|
|
7881
|
-
|
|
7882
|
-
|
|
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
|
-
|
|
7885
|
-
} catch
|
|
7886
|
-
|
|
8002
|
+
return await baseReadDir(d);
|
|
8003
|
+
} catch {
|
|
8004
|
+
return [];
|
|
7887
8005
|
}
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7896
|
-
|
|
7897
|
-
return {
|
|
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(
|
|
7914
|
-
const doReadFile = deps.readFileImpl ?? ((p) =>
|
|
7915
|
-
const doWriteFile = deps.writeFileImpl ?? ((p, d) =>
|
|
7916
|
-
const doMkdir = deps.mkdirImpl ?? (async (d) => void await
|
|
7917
|
-
const
|
|
7918
|
-
const
|
|
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 (!
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
8203
|
-
await
|
|
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
|
|
8207
|
-
await
|
|
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) =>
|
|
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
|
|
8515
|
-
import { readFile as
|
|
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
|
|
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
|
|
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
|
|
8581
|
-
await
|
|
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
|
|
8585
|
-
await
|
|
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
|
|
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
|
|
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": "
|
|
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.
|
|
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",
|