backthread 0.3.1 → 0.5.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 +1 -1
- package/dist-bundle/backthread.js +378 -102
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "backthread",
|
|
3
3
|
"displayName": "Backthread",
|
|
4
4
|
"description": "Backthread helps you understand your codebase while AI ships features. It captures the why behind every Claude Code session so you can ask \"how does X work?\" without digging through PRs.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.5.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Backthread"
|
|
8
8
|
},
|
|
@@ -7139,8 +7139,13 @@ function isNotFound(err) {
|
|
|
7139
7139
|
// src/version.ts
|
|
7140
7140
|
import { readFileSync } from "node:fs";
|
|
7141
7141
|
import { fileURLToPath } from "node:url";
|
|
7142
|
+
import { createRequire } from "node:module";
|
|
7142
7143
|
import { dirname, join as join2 } from "node:path";
|
|
7143
7144
|
var VERSION_HEADER = "x-backthread-version";
|
|
7145
|
+
var AGENT_HEADER = "x-backthread-agent";
|
|
7146
|
+
var REDACT_VERSION_HEADER = "x-backthread-redact-version";
|
|
7147
|
+
var PLATFORM_HEADER = "x-backthread-platform";
|
|
7148
|
+
var NODE_HEADER = "x-backthread-node";
|
|
7144
7149
|
var cached = null;
|
|
7145
7150
|
function cliVersion() {
|
|
7146
7151
|
if (cached !== null) return cached;
|
|
@@ -7155,8 +7160,58 @@ function cliVersion() {
|
|
|
7155
7160
|
}
|
|
7156
7161
|
return cached;
|
|
7157
7162
|
}
|
|
7163
|
+
var cachedRedact = null;
|
|
7164
|
+
function redactVersion() {
|
|
7165
|
+
if (cachedRedact !== null) return cachedRedact;
|
|
7166
|
+
if ("0.1.3".length > 0) {
|
|
7167
|
+
cachedRedact = "0.1.3";
|
|
7168
|
+
return cachedRedact;
|
|
7169
|
+
}
|
|
7170
|
+
cachedRedact = readRedactVersionFromDisk();
|
|
7171
|
+
return cachedRedact;
|
|
7172
|
+
}
|
|
7173
|
+
function readRedactVersionFromDisk() {
|
|
7174
|
+
try {
|
|
7175
|
+
const req = createRequire(import.meta.url);
|
|
7176
|
+
let dir = dirname(req.resolve("@backthread/redact"));
|
|
7177
|
+
for (let i = 0; i < 8; i++) {
|
|
7178
|
+
try {
|
|
7179
|
+
const pkg = JSON.parse(readFileSync(join2(dir, "package.json"), "utf8"));
|
|
7180
|
+
if (pkg.name === "@backthread/redact" && typeof pkg.version === "string" && pkg.version.length > 0) {
|
|
7181
|
+
return pkg.version;
|
|
7182
|
+
}
|
|
7183
|
+
} catch {
|
|
7184
|
+
}
|
|
7185
|
+
const parent = dirname(dir);
|
|
7186
|
+
if (parent === dir) break;
|
|
7187
|
+
dir = parent;
|
|
7188
|
+
}
|
|
7189
|
+
} catch {
|
|
7190
|
+
}
|
|
7191
|
+
return "0.0.0";
|
|
7192
|
+
}
|
|
7193
|
+
function platformTag() {
|
|
7194
|
+
return process.platform;
|
|
7195
|
+
}
|
|
7196
|
+
var cachedNodeMajor = null;
|
|
7197
|
+
function nodeMajor() {
|
|
7198
|
+
if (cachedNodeMajor !== null) return cachedNodeMajor;
|
|
7199
|
+
const m = /^(\d+)/.exec(process.versions.node ?? "");
|
|
7200
|
+
cachedNodeMajor = m ? m[1] : "";
|
|
7201
|
+
return cachedNodeMajor;
|
|
7202
|
+
}
|
|
7203
|
+
var requestAgent = "unknown";
|
|
7204
|
+
function setRequestAgent(agent) {
|
|
7205
|
+
if (typeof agent === "string" && agent.trim().length > 0) requestAgent = agent.trim();
|
|
7206
|
+
}
|
|
7158
7207
|
function versionHeaders() {
|
|
7159
|
-
return {
|
|
7208
|
+
return {
|
|
7209
|
+
[VERSION_HEADER]: cliVersion(),
|
|
7210
|
+
[AGENT_HEADER]: requestAgent,
|
|
7211
|
+
[REDACT_VERSION_HEADER]: redactVersion(),
|
|
7212
|
+
[PLATFORM_HEADER]: platformTag(),
|
|
7213
|
+
[NODE_HEADER]: nodeMajor()
|
|
7214
|
+
};
|
|
7160
7215
|
}
|
|
7161
7216
|
|
|
7162
7217
|
// src/claim.ts
|
|
@@ -7293,7 +7348,7 @@ async function ensureAuth(opts = {}) {
|
|
|
7293
7348
|
}
|
|
7294
7349
|
|
|
7295
7350
|
// src/capture.ts
|
|
7296
|
-
import { readFile as
|
|
7351
|
+
import { readFile as readFile9 } from "node:fs/promises";
|
|
7297
7352
|
|
|
7298
7353
|
// ../packages/redact/src/index.ts
|
|
7299
7354
|
var CODE_REDACTION = "[code redacted]";
|
|
@@ -7633,13 +7688,15 @@ async function serverInfer(transcript, config2, opts = {}) {
|
|
|
7633
7688
|
const persisted = rec.persisted === true;
|
|
7634
7689
|
const sessionId = typeof rec.sessionId === "string" ? rec.sessionId : transcript.sessionId ?? null;
|
|
7635
7690
|
const tokensSpent = typeof rec.tokensSpent === "number" ? rec.tokensSpent : void 0;
|
|
7691
|
+
const upgrade = typeof rec.upgrade === "string" && rec.upgrade.length > 0 ? rec.upgrade : void 0;
|
|
7636
7692
|
return {
|
|
7637
7693
|
ok: true,
|
|
7638
7694
|
model: "server",
|
|
7639
7695
|
decisions,
|
|
7640
7696
|
persisted,
|
|
7641
7697
|
sessionId,
|
|
7642
|
-
...tokensSpent !== void 0 ? { tokensSpent } : {}
|
|
7698
|
+
...tokensSpent !== void 0 ? { tokensSpent } : {},
|
|
7699
|
+
...upgrade ? { upgrade } : {}
|
|
7643
7700
|
};
|
|
7644
7701
|
}
|
|
7645
7702
|
async function inferDecisions(transcript, config2, opts = {}) {
|
|
@@ -7745,24 +7802,80 @@ async function maybeNudge(status, repo, sessionId, deps = {}) {
|
|
|
7745
7802
|
}
|
|
7746
7803
|
|
|
7747
7804
|
// src/firstRun.ts
|
|
7748
|
-
import { join as
|
|
7749
|
-
import { readFile as
|
|
7805
|
+
import { join as join10 } from "node:path";
|
|
7806
|
+
import { readFile as readFile8, writeFile as writeFile7, mkdir as mkdir7, chmod as chmod6 } from "node:fs/promises";
|
|
7750
7807
|
|
|
7751
7808
|
// src/install.ts
|
|
7752
|
-
import { readFile as
|
|
7809
|
+
import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir6 } from "node:fs/promises";
|
|
7753
7810
|
import { homedir as homedir5 } from "node:os";
|
|
7754
|
-
import { join as
|
|
7811
|
+
import { join as join9 } from "node:path";
|
|
7755
7812
|
|
|
7756
7813
|
// src/captureCommand.ts
|
|
7757
7814
|
import { stat } from "node:fs/promises";
|
|
7758
7815
|
import { homedir as homedir2 } from "node:os";
|
|
7816
|
+
import { join as join5 } from "node:path";
|
|
7817
|
+
|
|
7818
|
+
// src/upgradeNudge.ts
|
|
7759
7819
|
import { join as join4 } from "node:path";
|
|
7820
|
+
import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir3, chmod as chmod3 } from "node:fs/promises";
|
|
7821
|
+
function upgradeNudgeStatePath(env = process.env) {
|
|
7822
|
+
return join4(configDir(env), "upgrade-nudge.json");
|
|
7823
|
+
}
|
|
7824
|
+
var UPGRADE_NUDGE_THROTTLE_MS = 24 * 60 * 60 * 1e3;
|
|
7825
|
+
function parseState2(raw) {
|
|
7826
|
+
try {
|
|
7827
|
+
const obj = JSON.parse(raw);
|
|
7828
|
+
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
7829
|
+
const at = obj.lastUpgradeNudgeAt;
|
|
7830
|
+
if (typeof at === "number" && Number.isFinite(at)) return { lastUpgradeNudgeAt: at };
|
|
7831
|
+
}
|
|
7832
|
+
} catch {
|
|
7833
|
+
}
|
|
7834
|
+
return {};
|
|
7835
|
+
}
|
|
7836
|
+
async function readState2(env) {
|
|
7837
|
+
try {
|
|
7838
|
+
return parseState2(await readFile3(upgradeNudgeStatePath(env), "utf8"));
|
|
7839
|
+
} catch {
|
|
7840
|
+
return {};
|
|
7841
|
+
}
|
|
7842
|
+
}
|
|
7843
|
+
async function writeState2(state, env) {
|
|
7844
|
+
try {
|
|
7845
|
+
const dir = configDir(env);
|
|
7846
|
+
await mkdir3(dir, { recursive: true, mode: DIR_MODE });
|
|
7847
|
+
await chmod3(dir, DIR_MODE).catch(() => {
|
|
7848
|
+
});
|
|
7849
|
+
const path = upgradeNudgeStatePath(env);
|
|
7850
|
+
await writeFile3(path, JSON.stringify(state) + "\n", { mode: CONFIG_MODE });
|
|
7851
|
+
await chmod3(path, CONFIG_MODE).catch(() => {
|
|
7852
|
+
});
|
|
7853
|
+
} catch {
|
|
7854
|
+
}
|
|
7855
|
+
}
|
|
7856
|
+
async function maybeUpgradeNudge(upgrade, deps = {}) {
|
|
7857
|
+
try {
|
|
7858
|
+
if (typeof upgrade !== "string" || upgrade.trim().length === 0) return null;
|
|
7859
|
+
const env = deps.env ?? process.env;
|
|
7860
|
+
const now = deps.now ? deps.now() : Date.now();
|
|
7861
|
+
const state = await readState2(env);
|
|
7862
|
+
if (typeof state.lastUpgradeNudgeAt === "number" && now - state.lastUpgradeNudgeAt < UPGRADE_NUDGE_THROTTLE_MS) {
|
|
7863
|
+
return null;
|
|
7864
|
+
}
|
|
7865
|
+
await writeState2({ lastUpgradeNudgeAt: now }, env);
|
|
7866
|
+
return upgrade.trim();
|
|
7867
|
+
} catch {
|
|
7868
|
+
return null;
|
|
7869
|
+
}
|
|
7870
|
+
}
|
|
7871
|
+
|
|
7872
|
+
// src/captureCommand.ts
|
|
7760
7873
|
function slugifyCwd(cwd) {
|
|
7761
7874
|
return cwd.replace(/[^A-Za-z0-9]/g, "-");
|
|
7762
7875
|
}
|
|
7763
7876
|
function deriveTranscriptPath(sessionId, cwd, home) {
|
|
7764
7877
|
if (!sessionId || sessionId.trim().length === 0) return null;
|
|
7765
|
-
return
|
|
7878
|
+
return join5(home, ".claude", "projects", slugifyCwd(cwd), `${sessionId}.jsonl`);
|
|
7766
7879
|
}
|
|
7767
7880
|
async function defaultStat(path) {
|
|
7768
7881
|
try {
|
|
@@ -7805,7 +7918,11 @@ async function runManualCapture(input, deps = {}) {
|
|
|
7805
7918
|
} catch (e) {
|
|
7806
7919
|
return { text: `backthread capture: error \u2014 ${e.message}`, exitCode: 1, outcome: null };
|
|
7807
7920
|
}
|
|
7808
|
-
|
|
7921
|
+
let text = formatManualSummary(outcome);
|
|
7922
|
+
const nudge = await (deps.upgradeNudgeImpl ?? maybeUpgradeNudge)(outcome.upgrade);
|
|
7923
|
+
if (nudge) text += `
|
|
7924
|
+
${nudge}`;
|
|
7925
|
+
return { text, exitCode: exitCodeFor(outcome), outcome };
|
|
7809
7926
|
}
|
|
7810
7927
|
function exitCodeFor(o) {
|
|
7811
7928
|
if (o.status === "infer-failed" || o.status === "persist-failed" || o.status === "error") return 1;
|
|
@@ -7862,17 +7979,17 @@ function parseManualArgs(argv) {
|
|
|
7862
7979
|
}
|
|
7863
7980
|
|
|
7864
7981
|
// src/sweep.ts
|
|
7865
|
-
import { readFile as
|
|
7982
|
+
import { readFile as readFile5, stat as stat2, readdir } from "node:fs/promises";
|
|
7866
7983
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
7867
7984
|
import { homedir as homedir3 } from "node:os";
|
|
7868
|
-
import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as
|
|
7985
|
+
import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join7 } from "node:path";
|
|
7869
7986
|
|
|
7870
7987
|
// src/sweepLedger.ts
|
|
7871
|
-
import { join as
|
|
7872
|
-
import { readFile as
|
|
7988
|
+
import { join as join6 } from "node:path";
|
|
7989
|
+
import { readFile as readFile4, writeFile as writeFile4, mkdir as mkdir4, chmod as chmod4 } from "node:fs/promises";
|
|
7873
7990
|
var MAX_PROCESSED = 2e4;
|
|
7874
7991
|
function sweepStatePath(env = process.env) {
|
|
7875
|
-
return
|
|
7992
|
+
return join6(configDir(env), "sweep-state.json");
|
|
7876
7993
|
}
|
|
7877
7994
|
function parseSweepState(raw) {
|
|
7878
7995
|
try {
|
|
@@ -7897,7 +8014,7 @@ function serializeSweepState(state) {
|
|
|
7897
8014
|
}
|
|
7898
8015
|
async function readSweepState(env = process.env) {
|
|
7899
8016
|
try {
|
|
7900
|
-
return parseSweepState(await
|
|
8017
|
+
return parseSweepState(await readFile4(sweepStatePath(env), "utf8"));
|
|
7901
8018
|
} catch {
|
|
7902
8019
|
return { processed: [], lastSweptAt: {} };
|
|
7903
8020
|
}
|
|
@@ -7905,12 +8022,12 @@ async function readSweepState(env = process.env) {
|
|
|
7905
8022
|
async function writeSweepState(state, env = process.env) {
|
|
7906
8023
|
try {
|
|
7907
8024
|
const dir = configDir(env);
|
|
7908
|
-
await
|
|
7909
|
-
await
|
|
8025
|
+
await mkdir4(dir, { recursive: true, mode: DIR_MODE });
|
|
8026
|
+
await chmod4(dir, DIR_MODE).catch(() => {
|
|
7910
8027
|
});
|
|
7911
8028
|
const path = sweepStatePath(env);
|
|
7912
|
-
await
|
|
7913
|
-
await
|
|
8029
|
+
await writeFile4(path, serializeSweepState(state), { mode: CONFIG_MODE });
|
|
8030
|
+
await chmod4(path, CONFIG_MODE).catch(() => {
|
|
7914
8031
|
});
|
|
7915
8032
|
} catch {
|
|
7916
8033
|
}
|
|
@@ -8014,7 +8131,7 @@ function defaultMainRoot(cwd) {
|
|
|
8014
8131
|
stdio: ["ignore", "pipe", "ignore"]
|
|
8015
8132
|
}).trim();
|
|
8016
8133
|
if (!out) return null;
|
|
8017
|
-
const abs = isAbsolute2(out) ? out :
|
|
8134
|
+
const abs = isAbsolute2(out) ? out : join7(cwd, out);
|
|
8018
8135
|
return dirname2(abs.replace(/\/+$/, ""));
|
|
8019
8136
|
} catch {
|
|
8020
8137
|
return null;
|
|
@@ -8032,7 +8149,7 @@ async function runSweep(input = {}, deps = {}) {
|
|
|
8032
8149
|
return [];
|
|
8033
8150
|
}
|
|
8034
8151
|
};
|
|
8035
|
-
const baseReadFile = deps.readFileImpl ?? ((p) =>
|
|
8152
|
+
const baseReadFile = deps.readFileImpl ?? ((p) => readFile5(p, "utf8"));
|
|
8036
8153
|
const doReadFile = async (p) => {
|
|
8037
8154
|
try {
|
|
8038
8155
|
return await baseReadFile(p);
|
|
@@ -8089,7 +8206,7 @@ async function runSweep(input = {}, deps = {}) {
|
|
|
8089
8206
|
}
|
|
8090
8207
|
const mainRoot = doMainRoot(cwd) ?? cwd;
|
|
8091
8208
|
const mainSlug = slugifyCwd(mainRoot);
|
|
8092
|
-
const projectsRoot =
|
|
8209
|
+
const projectsRoot = join7(home, ".claude", "projects");
|
|
8093
8210
|
const entries = await doReadDir(projectsRoot);
|
|
8094
8211
|
const candidates = entries.filter((n) => n === mainSlug || n.startsWith(mainSlug + "-")).sort();
|
|
8095
8212
|
const skip = new Set(state.processed);
|
|
@@ -8102,12 +8219,12 @@ async function runSweep(input = {}, deps = {}) {
|
|
|
8102
8219
|
let captured = 0;
|
|
8103
8220
|
let decisions = 0;
|
|
8104
8221
|
for (const dirName of candidates) {
|
|
8105
|
-
const dir =
|
|
8222
|
+
const dir = join7(projectsRoot, dirName);
|
|
8106
8223
|
const files = (await doReadDir(dir)).filter((n) => n.endsWith(".jsonl")).sort();
|
|
8107
8224
|
if (files.length === 0) continue;
|
|
8108
8225
|
let embeddedCwd = null;
|
|
8109
8226
|
for (const file2 of files) {
|
|
8110
|
-
embeddedCwd = extractCwdFromRaw(await doReadFile(
|
|
8227
|
+
embeddedCwd = extractCwdFromRaw(await doReadFile(join7(dir, file2)));
|
|
8111
8228
|
if (embeddedCwd) break;
|
|
8112
8229
|
}
|
|
8113
8230
|
const cwdExists = embeddedCwd ? await doPathExists(embeddedCwd) : false;
|
|
@@ -8146,7 +8263,7 @@ async function runSweep(input = {}, deps = {}) {
|
|
|
8146
8263
|
try {
|
|
8147
8264
|
outcome = await run(
|
|
8148
8265
|
{
|
|
8149
|
-
transcript_path:
|
|
8266
|
+
transcript_path: join7(dir, file2),
|
|
8150
8267
|
cwd: cls.cwd ?? mainRoot,
|
|
8151
8268
|
session_id: sid,
|
|
8152
8269
|
hook_event_name: "SessionEnd"
|
|
@@ -8210,8 +8327,8 @@ async function runBackfill(input = {}, deps = {}) {
|
|
|
8210
8327
|
import { execFile } from "node:child_process";
|
|
8211
8328
|
import { promisify } from "node:util";
|
|
8212
8329
|
import { homedir as homedir4 } from "node:os";
|
|
8213
|
-
import { join as
|
|
8214
|
-
import { readFile as
|
|
8330
|
+
import { join as join8, dirname as dirname3 } from "node:path";
|
|
8331
|
+
import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir5, chmod as chmod5 } from "node:fs/promises";
|
|
8215
8332
|
var execFileP = promisify(execFile);
|
|
8216
8333
|
var MCP_COMMAND = "npx";
|
|
8217
8334
|
var MCP_ARGS = ["-y", "backthread", "mcp"];
|
|
@@ -8278,14 +8395,14 @@ function withNestedHook(settings, event, command, extra = {}) {
|
|
|
8278
8395
|
return { next: { ...settings, hooks }, changed: true };
|
|
8279
8396
|
}
|
|
8280
8397
|
async function writeJson(deps, path, obj) {
|
|
8281
|
-
const doMkdir = deps.mkdirImpl ?? (async (d) => void await
|
|
8282
|
-
const doWrite = deps.writeFileImpl ?? ((p, d) =>
|
|
8398
|
+
const doMkdir = deps.mkdirImpl ?? (async (d) => void await mkdir5(d, { recursive: true }));
|
|
8399
|
+
const doWrite = deps.writeFileImpl ?? ((p, d) => writeFile5(p, d));
|
|
8283
8400
|
await doMkdir(dirname3(path));
|
|
8284
8401
|
await doWrite(path, JSON.stringify(obj, null, 2) + "\n");
|
|
8285
8402
|
}
|
|
8286
8403
|
async function installGemini(home, deps) {
|
|
8287
|
-
const doRead = deps.readFileImpl ?? ((p) =>
|
|
8288
|
-
const path =
|
|
8404
|
+
const doRead = deps.readFileImpl ?? ((p) => readFile6(p, "utf8"));
|
|
8405
|
+
const path = join8(home, ".gemini", "settings.json");
|
|
8289
8406
|
const current = await loadJsonObject(doRead, path);
|
|
8290
8407
|
const a = withMcpServer(current);
|
|
8291
8408
|
const b = withNestedHook(a.next, "SessionEnd", hookCommand("gemini-cli"), { name: "backthread-capture" });
|
|
@@ -8293,9 +8410,9 @@ async function installGemini(home, deps) {
|
|
|
8293
8410
|
return [{ path, wrote: a.changed || b.changed }];
|
|
8294
8411
|
}
|
|
8295
8412
|
async function installCodex(home, deps) {
|
|
8296
|
-
const doRead = deps.readFileImpl ?? ((p) =>
|
|
8413
|
+
const doRead = deps.readFileImpl ?? ((p) => readFile6(p, "utf8"));
|
|
8297
8414
|
const writes = [];
|
|
8298
|
-
const tomlPath =
|
|
8415
|
+
const tomlPath = join8(home, ".codex", "config.toml");
|
|
8299
8416
|
let toml = "";
|
|
8300
8417
|
try {
|
|
8301
8418
|
toml = await doRead(tomlPath);
|
|
@@ -8310,13 +8427,13 @@ command = "${MCP_COMMAND}"
|
|
|
8310
8427
|
args = [${MCP_ARGS.map((a) => `"${a}"`).join(", ")}]
|
|
8311
8428
|
`;
|
|
8312
8429
|
const sep = toml.length === 0 ? "" : toml.endsWith("\n") ? "\n" : "\n\n";
|
|
8313
|
-
const doMkdir = deps.mkdirImpl ?? (async (d) => void await
|
|
8314
|
-
const doWrite = deps.writeFileImpl ?? ((p, d) =>
|
|
8430
|
+
const doMkdir = deps.mkdirImpl ?? (async (d) => void await mkdir5(d, { recursive: true }));
|
|
8431
|
+
const doWrite = deps.writeFileImpl ?? ((p, d) => writeFile5(p, d));
|
|
8315
8432
|
await doMkdir(dirname3(tomlPath));
|
|
8316
8433
|
await doWrite(tomlPath, toml + sep + block);
|
|
8317
8434
|
writes.push({ path: tomlPath, wrote: true });
|
|
8318
8435
|
}
|
|
8319
|
-
const hooksPath =
|
|
8436
|
+
const hooksPath = join8(home, ".codex", "hooks.json");
|
|
8320
8437
|
const current = await loadJsonObject(doRead, hooksPath);
|
|
8321
8438
|
const h = withNestedHook(current, "Stop", hookCommand("codex"), { timeout: 60 });
|
|
8322
8439
|
if (h.changed) await writeJson(deps, hooksPath, h.next);
|
|
@@ -8324,29 +8441,98 @@ args = [${MCP_ARGS.map((a) => `"${a}"`).join(", ")}]
|
|
|
8324
8441
|
return writes;
|
|
8325
8442
|
}
|
|
8326
8443
|
async function installCursor(home, deps) {
|
|
8327
|
-
const doRead = deps.readFileImpl ?? ((p) =>
|
|
8444
|
+
const doRead = deps.readFileImpl ?? ((p) => readFile6(p, "utf8"));
|
|
8445
|
+
const nodeBinDir = deps.nodeBinDir ?? dirname3(process.execPath);
|
|
8328
8446
|
const writes = [];
|
|
8329
|
-
const
|
|
8447
|
+
const scriptDir = join8(home, ".cursor", "hooks");
|
|
8448
|
+
const captureScriptPath = join8(scriptDir, "backthread-capture.sh");
|
|
8449
|
+
const mcpScriptPath = join8(scriptDir, "backthread-mcp.sh");
|
|
8450
|
+
writes.push(
|
|
8451
|
+
await writeCursorScript(
|
|
8452
|
+
deps,
|
|
8453
|
+
captureScriptPath,
|
|
8454
|
+
cursorWrapperScript(nodeBinDir, "capture --from-hook --agent cursor --detach")
|
|
8455
|
+
)
|
|
8456
|
+
);
|
|
8457
|
+
writes.push(await writeCursorScript(deps, mcpScriptPath, cursorWrapperScript(nodeBinDir, "mcp")));
|
|
8458
|
+
const mcpPath = join8(home, ".cursor", "mcp.json");
|
|
8330
8459
|
const mcpCurrent = await loadJsonObject(doRead, mcpPath);
|
|
8331
|
-
const m =
|
|
8460
|
+
const m = withCursorMcpServer(mcpCurrent, mcpScriptPath);
|
|
8332
8461
|
if (m.changed) await writeJson(deps, mcpPath, m.next);
|
|
8333
8462
|
writes.push({ path: mcpPath, wrote: m.changed });
|
|
8334
|
-
const hooksPath =
|
|
8463
|
+
const hooksPath = join8(home, ".cursor", "hooks.json");
|
|
8335
8464
|
const hooksCurrent = await loadJsonObject(doRead, hooksPath);
|
|
8336
|
-
const c = withCursorStopHook(hooksCurrent);
|
|
8465
|
+
const c = withCursorStopHook(hooksCurrent, captureScriptPath);
|
|
8337
8466
|
if (c.changed) await writeJson(deps, hooksPath, c.next);
|
|
8338
8467
|
writes.push({ path: hooksPath, wrote: c.changed });
|
|
8339
8468
|
return writes;
|
|
8340
8469
|
}
|
|
8341
|
-
function
|
|
8342
|
-
|
|
8470
|
+
function shSingleQuote(s) {
|
|
8471
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
8472
|
+
}
|
|
8473
|
+
function cursorWrapperScript(nodeBinDir, backthreadArgs) {
|
|
8474
|
+
return [
|
|
8475
|
+
"#!/bin/sh",
|
|
8476
|
+
"# Backthread wrapper for Cursor \u2014 generated by `backthread install --agent cursor` (ARP-692).",
|
|
8477
|
+
"#",
|
|
8478
|
+
"# Cursor is a GUI app and does NOT inherit your login/nvm shell PATH, so a bare",
|
|
8479
|
+
"# `npx`/`node` here may be missing or resolve to a too-old system Node (Backthread",
|
|
8480
|
+
"# needs Node >= 22.18). Prepend the Node bin dir detected at install time so capture",
|
|
8481
|
+
"# and the MCP server always run on a new-enough Node. (npx's `#!/usr/bin/env node`",
|
|
8482
|
+
"# shebang re-resolves node from PATH, so pinning PATH \u2014 not just an absolute npx \u2014 is",
|
|
8483
|
+
"# what actually guarantees the right Node.)",
|
|
8484
|
+
"#",
|
|
8485
|
+
"# If your Node later moves (a new nvm version, an uninstall), re-run:",
|
|
8486
|
+
"# npx backthread install --agent cursor",
|
|
8487
|
+
`NODE_BIN_DIR=${shSingleQuote(nodeBinDir)}`,
|
|
8488
|
+
'if [ -d "$NODE_BIN_DIR" ]; then',
|
|
8489
|
+
' PATH="$NODE_BIN_DIR:$PATH"',
|
|
8490
|
+
" export PATH",
|
|
8491
|
+
"fi",
|
|
8492
|
+
`exec npx -y backthread ${backthreadArgs}`
|
|
8493
|
+
].join("\n") + "\n";
|
|
8494
|
+
}
|
|
8495
|
+
async function writeCursorScript(deps, path, content) {
|
|
8496
|
+
const doRead = deps.readFileImpl ?? ((p) => readFile6(p, "utf8"));
|
|
8497
|
+
const doChmod = deps.chmodImpl ?? ((p, mode) => chmod5(p, mode));
|
|
8498
|
+
let existing = null;
|
|
8499
|
+
try {
|
|
8500
|
+
existing = await doRead(path);
|
|
8501
|
+
} catch (e) {
|
|
8502
|
+
if (!isNotFound2(e)) throw e;
|
|
8503
|
+
}
|
|
8504
|
+
if (existing === content) {
|
|
8505
|
+
await doChmod(path, 493).catch(() => {
|
|
8506
|
+
});
|
|
8507
|
+
return { path, wrote: false };
|
|
8508
|
+
}
|
|
8509
|
+
const doMkdir = deps.mkdirImpl ?? (async (d) => void await mkdir5(d, { recursive: true }));
|
|
8510
|
+
const doWrite = deps.writeFileImpl ?? ((p, d) => writeFile5(p, d));
|
|
8511
|
+
await doMkdir(dirname3(path));
|
|
8512
|
+
await doWrite(path, content);
|
|
8513
|
+
await doChmod(path, 493);
|
|
8514
|
+
return { path, wrote: true };
|
|
8515
|
+
}
|
|
8516
|
+
function withCursorMcpServer(settings, mcpScriptPath) {
|
|
8517
|
+
const mcpServers = asObject(settings.mcpServers);
|
|
8518
|
+
const desired = { command: mcpScriptPath, args: [] };
|
|
8519
|
+
if (JSON.stringify(mcpServers.backthread) === JSON.stringify(desired)) {
|
|
8520
|
+
return { next: settings, changed: false };
|
|
8521
|
+
}
|
|
8522
|
+
mcpServers.backthread = desired;
|
|
8523
|
+
return { next: { ...settings, mcpServers }, changed: true };
|
|
8524
|
+
}
|
|
8525
|
+
function withCursorStopHook(settings, captureScriptPath) {
|
|
8526
|
+
const legacyInline = hookCommand("cursor");
|
|
8343
8527
|
const hooks = asObject(settings.hooks);
|
|
8344
8528
|
const stop = Array.isArray(hooks.stop) ? [...hooks.stop] : [];
|
|
8345
|
-
const
|
|
8529
|
+
const hadDesired = stop.some((h) => h?.command === captureScriptPath);
|
|
8530
|
+
const next = stop.filter((h) => h?.command !== legacyInline);
|
|
8531
|
+
const removedLegacy = next.length !== stop.length;
|
|
8532
|
+
if (!hadDesired) next.push({ command: captureScriptPath });
|
|
8346
8533
|
const hasVersion = typeof settings.version === "number";
|
|
8347
|
-
if (
|
|
8348
|
-
|
|
8349
|
-
hooks.stop = stop;
|
|
8534
|
+
if (hadDesired && !removedLegacy && hasVersion) return { next: settings, changed: false };
|
|
8535
|
+
hooks.stop = next;
|
|
8350
8536
|
return { next: { ...settings, version: hasVersion ? settings.version : 1, hooks }, changed: true };
|
|
8351
8537
|
}
|
|
8352
8538
|
function cursorDeeplink() {
|
|
@@ -8416,16 +8602,19 @@ var TRUST_COPY = [
|
|
|
8416
8602
|
" code blocks replaced with [code redacted] \u2014 to Backthread's Worker, never source.",
|
|
8417
8603
|
" Full details: https://app.backthread.dev/security"
|
|
8418
8604
|
].join("\n");
|
|
8419
|
-
var HOOK_COMMAND = "npx backthread capture --from-hook --agent claude-code --detach";
|
|
8420
|
-
var LEGACY_HOOK_COMMANDS = [
|
|
8605
|
+
var HOOK_COMMAND = "npx backthread@latest capture --from-hook --agent claude-code --detach";
|
|
8606
|
+
var LEGACY_HOOK_COMMANDS = [
|
|
8607
|
+
"npx backthread capture",
|
|
8608
|
+
"npx backthread capture --from-hook --agent claude-code --detach"
|
|
8609
|
+
];
|
|
8421
8610
|
var OUR_HOOK_COMMANDS = /* @__PURE__ */ new Set([HOOK_COMMAND, ...LEGACY_HOOK_COMMANDS]);
|
|
8422
8611
|
async function registerHook(deps = {}) {
|
|
8423
|
-
const doReadFile = deps.readFileImpl ?? ((p) =>
|
|
8424
|
-
const doWriteFile = deps.writeFileImpl ?? ((p, d) =>
|
|
8425
|
-
const doMkdir = deps.mkdirImpl ?? (async (d) => void await
|
|
8612
|
+
const doReadFile = deps.readFileImpl ?? ((p) => readFile7(p, "utf8"));
|
|
8613
|
+
const doWriteFile = deps.writeFileImpl ?? ((p, d) => writeFile6(p, d));
|
|
8614
|
+
const doMkdir = deps.mkdirImpl ?? (async (d) => void await mkdir6(d, { recursive: true }));
|
|
8426
8615
|
const home = deps.home ?? homedir5();
|
|
8427
|
-
const settingsDir =
|
|
8428
|
-
const settingsPath =
|
|
8616
|
+
const settingsDir = join9(home, ".claude");
|
|
8617
|
+
const settingsPath = join9(settingsDir, "settings.json");
|
|
8429
8618
|
let settings = {};
|
|
8430
8619
|
let raw = null;
|
|
8431
8620
|
try {
|
|
@@ -8502,6 +8691,67 @@ function rewriteLegacyCommand(group) {
|
|
|
8502
8691
|
});
|
|
8503
8692
|
return changed ? { ...group, hooks: nextInner } : group;
|
|
8504
8693
|
}
|
|
8694
|
+
function stripSessionEndHook(settings) {
|
|
8695
|
+
const hooksVal = settings.hooks;
|
|
8696
|
+
if (!hooksVal || typeof hooksVal !== "object" || Array.isArray(hooksVal)) return null;
|
|
8697
|
+
const seVal = hooksVal.SessionEnd;
|
|
8698
|
+
if (!Array.isArray(seVal)) return null;
|
|
8699
|
+
let changed = false;
|
|
8700
|
+
const nextSessionEnd = [];
|
|
8701
|
+
for (const group of seVal) {
|
|
8702
|
+
const inner = group?.hooks;
|
|
8703
|
+
if (!group || typeof group !== "object" || !Array.isArray(inner)) {
|
|
8704
|
+
nextSessionEnd.push(group);
|
|
8705
|
+
continue;
|
|
8706
|
+
}
|
|
8707
|
+
const keptInner = inner.filter((h) => {
|
|
8708
|
+
const cmd = h?.command;
|
|
8709
|
+
const isOurs = typeof cmd === "string" && OUR_HOOK_COMMANDS.has(cmd);
|
|
8710
|
+
if (isOurs) changed = true;
|
|
8711
|
+
return !isOurs;
|
|
8712
|
+
});
|
|
8713
|
+
if (keptInner.length === 0) continue;
|
|
8714
|
+
if (keptInner.length !== inner.length) {
|
|
8715
|
+
nextSessionEnd.push({ ...group, hooks: keptInner });
|
|
8716
|
+
} else {
|
|
8717
|
+
nextSessionEnd.push(group);
|
|
8718
|
+
}
|
|
8719
|
+
}
|
|
8720
|
+
if (!changed) return null;
|
|
8721
|
+
const hooks = { ...hooksVal };
|
|
8722
|
+
if (nextSessionEnd.length === 0) delete hooks.SessionEnd;
|
|
8723
|
+
else hooks.SessionEnd = nextSessionEnd;
|
|
8724
|
+
const next = { ...settings, hooks };
|
|
8725
|
+
if (Object.keys(hooks).length === 0) delete next.hooks;
|
|
8726
|
+
return next;
|
|
8727
|
+
}
|
|
8728
|
+
async function unregisterProjectHook(cwd, deps = {}) {
|
|
8729
|
+
const doReadFile = deps.readFileImpl ?? ((p) => readFile7(p, "utf8"));
|
|
8730
|
+
const doWriteFile = deps.writeFileImpl ?? ((p, d) => writeFile6(p, d));
|
|
8731
|
+
const settingsPath = join9(cwd, ".claude", "settings.json");
|
|
8732
|
+
let raw;
|
|
8733
|
+
try {
|
|
8734
|
+
raw = await doReadFile(settingsPath);
|
|
8735
|
+
} catch (e) {
|
|
8736
|
+
if (isNotFound3(e)) return { stripped: false, path: settingsPath };
|
|
8737
|
+
throw e;
|
|
8738
|
+
}
|
|
8739
|
+
let parsed;
|
|
8740
|
+
try {
|
|
8741
|
+
parsed = JSON.parse(raw);
|
|
8742
|
+
} catch {
|
|
8743
|
+
throw new Error(
|
|
8744
|
+
`${settingsPath} exists but is not valid JSON \u2014 refusing to modify it. Remove the stale SessionEnd hook manually if present.`
|
|
8745
|
+
);
|
|
8746
|
+
}
|
|
8747
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
8748
|
+
throw new Error(`${settingsPath} is not a JSON object \u2014 refusing to modify it.`);
|
|
8749
|
+
}
|
|
8750
|
+
const stripped = stripSessionEndHook(parsed);
|
|
8751
|
+
if (stripped === null) return { stripped: false, path: settingsPath };
|
|
8752
|
+
await doWriteFile(settingsPath, JSON.stringify(stripped, null, 2) + "\n");
|
|
8753
|
+
return { stripped: true, path: settingsPath };
|
|
8754
|
+
}
|
|
8505
8755
|
async function runInstall(opts = {}, deps = {}) {
|
|
8506
8756
|
const env = opts.env ?? process.env;
|
|
8507
8757
|
const log = opts.log ?? ((m) => console.error(m));
|
|
@@ -8551,6 +8801,7 @@ Backthread is set up for ${targetAgent}. New sessions are captured automatically
|
|
|
8551
8801
|
return { exitCode: exitCode2, authed, hookRegistered: agentResult !== null, backfill: null, agentResult };
|
|
8552
8802
|
}
|
|
8553
8803
|
let hookRegistered = false;
|
|
8804
|
+
let projectHookMigrated = false;
|
|
8554
8805
|
if (opts.skipHook) {
|
|
8555
8806
|
log("[2/3] Hook: skipped (registered by the plugin manifest).");
|
|
8556
8807
|
} else {
|
|
@@ -8564,6 +8815,17 @@ Backthread is set up for ${targetAgent}. New sessions are captured automatically
|
|
|
8564
8815
|
log(`[2/3] Hook: not registered \u2014 ${e.message}`);
|
|
8565
8816
|
log(" You can add it manually (see the README \u203A Registering the hook).");
|
|
8566
8817
|
}
|
|
8818
|
+
if (hookRegistered) {
|
|
8819
|
+
try {
|
|
8820
|
+
const { stripped, path } = await unregisterProjectHook(cwd, deps);
|
|
8821
|
+
if (stripped) {
|
|
8822
|
+
projectHookMigrated = true;
|
|
8823
|
+
log(` Migrated: removed the stale project-scope SessionEnd hook from ${path} (it now lives at user scope).`);
|
|
8824
|
+
}
|
|
8825
|
+
} catch (e) {
|
|
8826
|
+
log(` Note: left the project-scope settings.json untouched \u2014 ${e.message}`);
|
|
8827
|
+
}
|
|
8828
|
+
}
|
|
8567
8829
|
}
|
|
8568
8830
|
let backfill = null;
|
|
8569
8831
|
if (opts.skipBackfill) {
|
|
@@ -8580,7 +8842,7 @@ Backthread is set up for ${targetAgent}. New sessions are captured automatically
|
|
|
8580
8842
|
}
|
|
8581
8843
|
log("\nBackthread is set up. New sessions are captured automatically when they end.");
|
|
8582
8844
|
const exitCode = !authed && !opts.skipAuth ? 1 : 0;
|
|
8583
|
-
return { exitCode, authed, hookRegistered, backfill, agentResult: null };
|
|
8845
|
+
return { exitCode, authed, hookRegistered, backfill, agentResult: null, projectHookMigrated };
|
|
8584
8846
|
}
|
|
8585
8847
|
|
|
8586
8848
|
// src/entry.ts
|
|
@@ -8731,7 +8993,7 @@ function normalizeState(raw) {
|
|
|
8731
8993
|
|
|
8732
8994
|
// src/firstRun.ts
|
|
8733
8995
|
function firstRunStatePath(env = process.env) {
|
|
8734
|
-
return
|
|
8996
|
+
return join10(configDir(env), "first-run.json");
|
|
8735
8997
|
}
|
|
8736
8998
|
function parseFirstRunState(raw) {
|
|
8737
8999
|
try {
|
|
@@ -8750,7 +9012,7 @@ function parseFirstRunState(raw) {
|
|
|
8750
9012
|
}
|
|
8751
9013
|
async function readFirstRunState(env = process.env) {
|
|
8752
9014
|
try {
|
|
8753
|
-
return parseFirstRunState(await
|
|
9015
|
+
return parseFirstRunState(await readFile8(firstRunStatePath(env), "utf8"));
|
|
8754
9016
|
} catch {
|
|
8755
9017
|
return {};
|
|
8756
9018
|
}
|
|
@@ -8760,12 +9022,12 @@ async function updateFirstRunState(patch, env = process.env) {
|
|
|
8760
9022
|
const current = await readFirstRunState(env);
|
|
8761
9023
|
const next = { ...current, ...patch };
|
|
8762
9024
|
const dir = configDir(env);
|
|
8763
|
-
await
|
|
8764
|
-
await
|
|
9025
|
+
await mkdir7(dir, { recursive: true, mode: DIR_MODE });
|
|
9026
|
+
await chmod6(dir, DIR_MODE).catch(() => {
|
|
8765
9027
|
});
|
|
8766
9028
|
const path = firstRunStatePath(env);
|
|
8767
|
-
await
|
|
8768
|
-
await
|
|
9029
|
+
await writeFile7(path, JSON.stringify(next) + "\n", { mode: CONFIG_MODE });
|
|
9030
|
+
await chmod6(path, CONFIG_MODE).catch(() => {
|
|
8769
9031
|
});
|
|
8770
9032
|
} catch {
|
|
8771
9033
|
}
|
|
@@ -8773,13 +9035,13 @@ async function updateFirstRunState(patch, env = process.env) {
|
|
|
8773
9035
|
async function maybeShowTrustGate(deps = {}) {
|
|
8774
9036
|
try {
|
|
8775
9037
|
const env = deps.env ?? process.env;
|
|
8776
|
-
const
|
|
8777
|
-
const state = await
|
|
9038
|
+
const readState4 = deps.readStateImpl ?? readFirstRunState;
|
|
9039
|
+
const state = await readState4(env);
|
|
8778
9040
|
if (state.onboarded === true || state.trustShown === true) return false;
|
|
8779
9041
|
const log = deps.log ?? ((m) => console.error(m));
|
|
8780
9042
|
log(TRUST_COPY);
|
|
8781
|
-
const
|
|
8782
|
-
await
|
|
9043
|
+
const writeState4 = deps.writeStateImpl ?? updateFirstRunState;
|
|
9044
|
+
await writeState4({ trustShown: true }, env);
|
|
8783
9045
|
return true;
|
|
8784
9046
|
} catch {
|
|
8785
9047
|
return false;
|
|
@@ -8790,8 +9052,8 @@ async function runStart(opts = {}, deps = {}) {
|
|
|
8790
9052
|
const log = opts.log ?? ((m) => console.error(m));
|
|
8791
9053
|
const cwd = opts.cwd ?? process.cwd();
|
|
8792
9054
|
const entry = opts.entry ?? detectEntry({ claim: opts.claim, env });
|
|
8793
|
-
const
|
|
8794
|
-
const existingState = await
|
|
9055
|
+
const readState4 = deps.readStateImpl ?? readFirstRunState;
|
|
9056
|
+
const existingState = await readState4(env).catch(() => ({}));
|
|
8795
9057
|
if (existingState.onboarded === true) {
|
|
8796
9058
|
const readCfg = deps.readConfigImpl ?? readConfig;
|
|
8797
9059
|
const cfg = await readCfg(env).catch(() => ({}));
|
|
@@ -8840,8 +9102,8 @@ async function runStart(opts = {}, deps = {}) {
|
|
|
8840
9102
|
() => ({ status: "error", detail: "state fetch failed (swallowed)" })
|
|
8841
9103
|
);
|
|
8842
9104
|
log("\n" + renderNextStep(stateOut, env));
|
|
8843
|
-
const
|
|
8844
|
-
await
|
|
9105
|
+
const writeState4 = deps.writeStateImpl ?? updateFirstRunState;
|
|
9106
|
+
await writeState4({ onboarded: true, trustShown: true }, env);
|
|
8845
9107
|
return { exitCode: 0, status: "onboarded", authed: true };
|
|
8846
9108
|
}
|
|
8847
9109
|
function renderNextStep(out, env = process.env) {
|
|
@@ -8882,13 +9144,13 @@ async function maybeFirstCaptureConfirm(count, repoConnected, repo, deps = {}) {
|
|
|
8882
9144
|
if (!repoConnected || !repo) return false;
|
|
8883
9145
|
if (!(count > 0)) return false;
|
|
8884
9146
|
const env = deps.env ?? process.env;
|
|
8885
|
-
const
|
|
8886
|
-
const state = await
|
|
9147
|
+
const readState4 = deps.readStateImpl ?? readFirstRunState;
|
|
9148
|
+
const state = await readState4(env);
|
|
8887
9149
|
if (state.firstCaptureShown === true) return false;
|
|
8888
9150
|
const log = deps.log ?? ((m) => console.error(m));
|
|
8889
9151
|
log(firstCaptureMessage(count, repo, env));
|
|
8890
|
-
const
|
|
8891
|
-
await
|
|
9152
|
+
const writeState4 = deps.writeStateImpl ?? updateFirstRunState;
|
|
9153
|
+
await writeState4({ firstCaptureShown: true }, env);
|
|
8892
9154
|
return true;
|
|
8893
9155
|
} catch {
|
|
8894
9156
|
return false;
|
|
@@ -8925,7 +9187,7 @@ function readStream(stream) {
|
|
|
8925
9187
|
async function runCapture(input, deps = {}) {
|
|
8926
9188
|
const env = deps.env ?? process.env;
|
|
8927
9189
|
const log = deps.log ?? ((m) => console.error(m));
|
|
8928
|
-
const doReadFile = deps.readFileImpl ?? ((p) =>
|
|
9190
|
+
const doReadFile = deps.readFileImpl ?? ((p) => readFile9(p, "utf8"));
|
|
8929
9191
|
const doReadConfig = deps.readConfigImpl ?? readConfig;
|
|
8930
9192
|
const fireEnsureAuth = deps.ensureAuthImpl ?? ((e) => {
|
|
8931
9193
|
void ensureAuth({ env: e }).catch(() => {
|
|
@@ -8987,6 +9249,7 @@ async function runCapture(input, deps = {}) {
|
|
|
8987
9249
|
if (!result.ok) {
|
|
8988
9250
|
return { status: "infer-failed", detail: result.error ?? "inference failed (no detail)." };
|
|
8989
9251
|
}
|
|
9252
|
+
const inferUpgrade = result.upgrade;
|
|
8990
9253
|
if (result.persisted) {
|
|
8991
9254
|
const confirm = deps.firstCaptureConfirmImpl ?? maybeFirstCaptureConfirm;
|
|
8992
9255
|
await confirm(result.decisions.length, true, repo, { env, log }).catch(() => false);
|
|
@@ -8995,7 +9258,8 @@ async function runCapture(input, deps = {}) {
|
|
|
8995
9258
|
detail: `inference router persisted ${result.decisions.length} decision(s) server-side.`,
|
|
8996
9259
|
count: result.decisions.length,
|
|
8997
9260
|
repoConnected: true,
|
|
8998
|
-
turnCount
|
|
9261
|
+
turnCount,
|
|
9262
|
+
...inferUpgrade ? { upgrade: inferUpgrade } : {}
|
|
8999
9263
|
};
|
|
9000
9264
|
}
|
|
9001
9265
|
if (result.decisions.length === 0) {
|
|
@@ -9003,7 +9267,8 @@ async function runCapture(input, deps = {}) {
|
|
|
9003
9267
|
status: "nothing-to-capture",
|
|
9004
9268
|
detail: "inference returned no decisions for this session.",
|
|
9005
9269
|
count: 0,
|
|
9006
|
-
turnCount
|
|
9270
|
+
turnCount,
|
|
9271
|
+
...inferUpgrade ? { upgrade: inferUpgrade } : {}
|
|
9007
9272
|
};
|
|
9008
9273
|
}
|
|
9009
9274
|
if (!repo) {
|
|
@@ -9011,7 +9276,8 @@ async function runCapture(input, deps = {}) {
|
|
|
9011
9276
|
status: "nothing-to-capture",
|
|
9012
9277
|
detail: "derived decisions but could not resolve a repo from cwd (no git remote) \u2014 nothing to claim them under; skipped.",
|
|
9013
9278
|
count: 0,
|
|
9014
|
-
turnCount
|
|
9279
|
+
turnCount,
|
|
9280
|
+
...inferUpgrade ? { upgrade: inferUpgrade } : {}
|
|
9015
9281
|
};
|
|
9016
9282
|
}
|
|
9017
9283
|
const out = await persistDerived(result.decisions, repo, config2, decidedAt, {
|
|
@@ -9081,7 +9347,7 @@ async function persistDerived(decisions, repo, config2, decidedAt, ctx) {
|
|
|
9081
9347
|
const rec = payload && typeof payload === "object" ? payload : {};
|
|
9082
9348
|
const count = typeof rec.count === "number" ? rec.count : decisions.length;
|
|
9083
9349
|
const repoConnected = rec.repoConnected === true;
|
|
9084
|
-
const upgrade = typeof rec.upgrade === "string" && rec.upgrade.length > 0 ? rec.upgrade :
|
|
9350
|
+
const upgrade = typeof rec.upgrade === "string" && rec.upgrade.length > 0 ? rec.upgrade : void 0;
|
|
9085
9351
|
const base = repoConnected ? `captured ${count} decision(s) to ${repo.owner}/${repo.name}.` : `captured ${count} decision(s) (repo not yet connected \u2014 held as pending).`;
|
|
9086
9352
|
await maybeNudge(parseRepoStatus(rec.repoStatus), repo, ctx.sessionId, {
|
|
9087
9353
|
env: ctx.env,
|
|
@@ -9092,16 +9358,17 @@ async function persistDerived(decisions, repo, config2, decidedAt, ctx) {
|
|
|
9092
9358
|
await confirm(count, repoConnected, repo, { env: ctx.env, log: ctx.log }).catch(() => false);
|
|
9093
9359
|
return {
|
|
9094
9360
|
status: "persisted",
|
|
9095
|
-
detail:
|
|
9361
|
+
detail: base,
|
|
9096
9362
|
count,
|
|
9097
|
-
repoConnected
|
|
9363
|
+
repoConnected,
|
|
9364
|
+
...upgrade ? { upgrade } : {}
|
|
9098
9365
|
};
|
|
9099
9366
|
}
|
|
9100
9367
|
|
|
9101
9368
|
// src/fromHook.ts
|
|
9102
9369
|
import { spawn as spawn2 } from "node:child_process";
|
|
9103
|
-
import { join as
|
|
9104
|
-
import { readFile as
|
|
9370
|
+
import { join as join11 } from "node:path";
|
|
9371
|
+
import { readFile as readFile10, writeFile as writeFile8, mkdir as mkdir8, chmod as chmod7 } from "node:fs/promises";
|
|
9105
9372
|
var KNOWN_AGENTS = /* @__PURE__ */ new Set([
|
|
9106
9373
|
"claude-code",
|
|
9107
9374
|
"codex",
|
|
@@ -9142,10 +9409,10 @@ function normalizeHookInput(payload, _agent) {
|
|
|
9142
9409
|
return out;
|
|
9143
9410
|
}
|
|
9144
9411
|
function captureStatePath(env = process.env) {
|
|
9145
|
-
return
|
|
9412
|
+
return join11(configDir(env), "capture-sessions.json");
|
|
9146
9413
|
}
|
|
9147
9414
|
var MAX_REMEMBERED2 = 200;
|
|
9148
|
-
function
|
|
9415
|
+
function parseState3(raw) {
|
|
9149
9416
|
try {
|
|
9150
9417
|
const obj = JSON.parse(raw);
|
|
9151
9418
|
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
@@ -9163,49 +9430,49 @@ function parseState2(raw) {
|
|
|
9163
9430
|
}
|
|
9164
9431
|
return { captured: [], watermarks: {} };
|
|
9165
9432
|
}
|
|
9166
|
-
async function
|
|
9433
|
+
async function readState3(env) {
|
|
9167
9434
|
try {
|
|
9168
|
-
return
|
|
9435
|
+
return parseState3(await readFile10(captureStatePath(env), "utf8"));
|
|
9169
9436
|
} catch {
|
|
9170
9437
|
return { captured: [], watermarks: {} };
|
|
9171
9438
|
}
|
|
9172
9439
|
}
|
|
9173
|
-
async function
|
|
9440
|
+
async function writeState3(state, env) {
|
|
9174
9441
|
try {
|
|
9175
9442
|
const dir = configDir(env);
|
|
9176
|
-
await
|
|
9177
|
-
await
|
|
9443
|
+
await mkdir8(dir, { recursive: true, mode: DIR_MODE });
|
|
9444
|
+
await chmod7(dir, DIR_MODE).catch(() => {
|
|
9178
9445
|
});
|
|
9179
9446
|
const path = captureStatePath(env);
|
|
9180
|
-
await
|
|
9181
|
-
await
|
|
9447
|
+
await writeFile8(path, JSON.stringify(state) + "\n", { mode: CONFIG_MODE });
|
|
9448
|
+
await chmod7(path, CONFIG_MODE).catch(() => {
|
|
9182
9449
|
});
|
|
9183
9450
|
} catch {
|
|
9184
9451
|
}
|
|
9185
9452
|
}
|
|
9186
9453
|
async function wasSessionCaptured(sessionId, env = process.env) {
|
|
9187
9454
|
if (!sessionId || sessionId.trim().length === 0) return false;
|
|
9188
|
-
const state = await
|
|
9455
|
+
const state = await readState3(env);
|
|
9189
9456
|
return state.captured.includes(sessionId);
|
|
9190
9457
|
}
|
|
9191
9458
|
async function markSessionCaptured(sessionId, env = process.env) {
|
|
9192
9459
|
if (!sessionId || sessionId.trim().length === 0) return;
|
|
9193
|
-
const state = await
|
|
9460
|
+
const state = await readState3(env);
|
|
9194
9461
|
if (state.captured.includes(sessionId)) return;
|
|
9195
9462
|
const captured = [...state.captured, sessionId];
|
|
9196
9463
|
if (captured.length > MAX_REMEMBERED2) captured.splice(0, captured.length - MAX_REMEMBERED2);
|
|
9197
9464
|
const { [sessionId]: _dropped, ...watermarks } = state.watermarks;
|
|
9198
|
-
await
|
|
9465
|
+
await writeState3({ captured, watermarks }, env);
|
|
9199
9466
|
}
|
|
9200
9467
|
async function captureWatermark(sessionId, env = process.env) {
|
|
9201
9468
|
if (!sessionId || sessionId.trim().length === 0) return 0;
|
|
9202
|
-
const state = await
|
|
9469
|
+
const state = await readState3(env);
|
|
9203
9470
|
return state.watermarks[sessionId] ?? 0;
|
|
9204
9471
|
}
|
|
9205
9472
|
async function setCaptureWatermark(sessionId, turnCount, env = process.env) {
|
|
9206
9473
|
if (!sessionId || sessionId.trim().length === 0) return;
|
|
9207
9474
|
if (typeof turnCount !== "number" || !Number.isFinite(turnCount) || turnCount < 0) return;
|
|
9208
|
-
const state = await
|
|
9475
|
+
const state = await readState3(env);
|
|
9209
9476
|
const prev = state.watermarks[sessionId] ?? 0;
|
|
9210
9477
|
if (turnCount <= prev) return;
|
|
9211
9478
|
const { [sessionId]: _old, ...rest } = state.watermarks;
|
|
@@ -9214,7 +9481,7 @@ async function setCaptureWatermark(sessionId, turnCount, env = process.env) {
|
|
|
9214
9481
|
if (keys.length > MAX_REMEMBERED2) {
|
|
9215
9482
|
for (const k of keys.slice(0, keys.length - MAX_REMEMBERED2)) delete watermarks[k];
|
|
9216
9483
|
}
|
|
9217
|
-
await
|
|
9484
|
+
await writeState3({ captured: state.captured, watermarks }, env);
|
|
9218
9485
|
}
|
|
9219
9486
|
function spawnDetached(rawPayload, agent, deps = {}) {
|
|
9220
9487
|
const doSpawn = deps.spawnImpl ?? spawn2;
|
|
@@ -33487,15 +33754,16 @@ async function queryDecisions(input, deps = {}) {
|
|
|
33487
33754
|
const rec = payload && typeof payload === "object" ? payload : {};
|
|
33488
33755
|
const flows = normalizeFlows(rec.flows);
|
|
33489
33756
|
const decisions = normalizeDecisions(rec.decisions);
|
|
33490
|
-
const upgrade = typeof rec.upgrade === "string" && rec.upgrade.length > 0 ? rec.upgrade :
|
|
33757
|
+
const upgrade = typeof rec.upgrade === "string" && rec.upgrade.length > 0 ? rec.upgrade : void 0;
|
|
33491
33758
|
const base = `${flows.length} flow(s), ${decisions.length} decision(s) for ${repo.owner}/${repo.name}.`;
|
|
33492
33759
|
return {
|
|
33493
33760
|
status: "ok",
|
|
33494
|
-
detail:
|
|
33761
|
+
detail: base,
|
|
33495
33762
|
repo,
|
|
33496
33763
|
flows,
|
|
33497
33764
|
decisions,
|
|
33498
|
-
deepLink
|
|
33765
|
+
deepLink,
|
|
33766
|
+
...upgrade ? { upgrade } : {}
|
|
33499
33767
|
};
|
|
33500
33768
|
} catch (e) {
|
|
33501
33769
|
return { status: "error", detail: `query failed (swallowed): ${e.message}` };
|
|
@@ -33563,7 +33831,12 @@ async function handleQueryTool(args = {}, deps = {}) {
|
|
|
33563
33831
|
const run = deps.queryDecisionsImpl ?? queryDecisions;
|
|
33564
33832
|
try {
|
|
33565
33833
|
const outcome = await run({ repo: args.repo, cwd: args.cwd }, deps.queryDeps);
|
|
33566
|
-
|
|
33834
|
+
let text = formatQueryOutcome(outcome, args.question);
|
|
33835
|
+
const nudge = await (deps.upgradeNudgeImpl ?? maybeUpgradeNudge)(outcome.upgrade);
|
|
33836
|
+
if (nudge) text += `
|
|
33837
|
+
|
|
33838
|
+
${nudge}`;
|
|
33839
|
+
return textResult(text, outcome.status !== "ok");
|
|
33567
33840
|
} catch (e) {
|
|
33568
33841
|
return textResult(`query: error \u2014 ${e.message}`, true);
|
|
33569
33842
|
}
|
|
@@ -33730,9 +34003,11 @@ async function main(argv, deps = {}) {
|
|
|
33730
34003
|
if (rest.includes("--from-hook")) {
|
|
33731
34004
|
const raw = await readRawHookInput();
|
|
33732
34005
|
const detach = rest.includes("--detach") && !rest.includes("--no-detach");
|
|
34006
|
+
const agent = parseAgent(flagValue(rest, "--agent"));
|
|
34007
|
+
setRequestAgent(agent);
|
|
33733
34008
|
const result = await runFromHook({
|
|
33734
34009
|
rawPayload: raw,
|
|
33735
|
-
agent
|
|
34010
|
+
agent,
|
|
33736
34011
|
detach
|
|
33737
34012
|
});
|
|
33738
34013
|
if (result.stdout) console.log(JSON.stringify(result.stdout));
|
|
@@ -33749,6 +34024,7 @@ async function main(argv, deps = {}) {
|
|
|
33749
34024
|
return result.exitCode;
|
|
33750
34025
|
}
|
|
33751
34026
|
try {
|
|
34027
|
+
setRequestAgent("claude-code");
|
|
33752
34028
|
const hookInput = await readHookInput();
|
|
33753
34029
|
const outcome = await runCapture(hookInput);
|
|
33754
34030
|
console.error(`backthread capture: ${outcome.status} \u2014 ${outcome.detail}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backthread",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Backthread helps you understand your codebase while AI ships features. The CLI captures the why behind every AI session and lets you ask how your codebase works, right from the terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Backthread",
|