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