truthspine 0.2.1 → 0.2.2
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/README.md +23 -11
- package/bin/truthspine-mcp.mjs +7 -4
- package/lib/claude-lifecycle-contract.cjs +27 -15
- package/lib/installed-mcp-launcher.cjs +276 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,12 +4,24 @@
|
|
|
4
4
|
|
|
5
5
|
The free MCP connector for TruthSpine, the local-first project-truth app for AI agents.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Stop re-explaining your project to every new AI chat. It works on your computer, with no cloud account and nothing extra to install.
|
|
8
8
|
|
|
9
|
-
One checked record of what was decided, what was done, what failed and what comes next. Every connected chat
|
|
9
|
+
One checked record of what was decided, what was done, what failed and what comes next. Every connected chat checks your project before it answers, so you never re-explain it. Your project stays on your computer.
|
|
10
10
|
|
|
11
11
|
This is the free connector. It needs the TruthSpine desktop app installed on the same computer: the [Microsoft Store](https://apps.microsoft.com/detail/9pbn25k75vdj) on Windows, the [Mac App Store](https://apps.apple.com/app/id6794478470) on macOS, or the AppImage from [truthspine.app](https://truthspine.app/download) on Linux.
|
|
12
12
|
|
|
13
|
+
## See it
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
*Codex in VS Code. Real recording, waits shortened.*
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
13
25
|
## Setup
|
|
14
26
|
|
|
15
27
|
1. Install and open the TruthSpine app, and connect your project folder in it.
|
|
@@ -43,18 +55,18 @@ The TruthSpine app can also write these settings for you: open Connections and c
|
|
|
43
55
|
|
|
44
56
|
## Tools
|
|
45
57
|
|
|
46
|
-
- `
|
|
47
|
-
- `
|
|
48
|
-
- `
|
|
49
|
-
- `
|
|
58
|
+
- `attach`: Attach this chat to the project's TruthSpine record and receive the current brief: decisions, completed work, evidence and next steps.
|
|
59
|
+
- `consult`: Ask what is true about the project, what was decided and why, or what to do next. Read only.
|
|
60
|
+
- `record`: Save a decision, finished work, a failure or the next step to the project's record on this computer.
|
|
61
|
+
- `history`: Read what was saved recently, or confirm that a save was recorded. Read only.
|
|
50
62
|
|
|
51
63
|
## Licence
|
|
52
64
|
|
|
53
|
-
This connector is
|
|
54
|
-
|
|
55
|
-
On Windows,
|
|
56
|
-
On Mac,
|
|
57
|
-
On Linux, the TruthSpine AppImage from truthspine.app
|
|
65
|
+
This connector is open source (Apache License 2.0). It needs the TruthSpine app, which you get separately.
|
|
66
|
+
The connector needs no license of its own.
|
|
67
|
+
On Windows, get the TruthSpine app from the Microsoft Store.
|
|
68
|
+
On Mac, get the TruthSpine app from the App Store.
|
|
69
|
+
On Linux, get the TruthSpine AppImage from truthspine.app.
|
|
58
70
|
|
|
59
71
|
## Privacy Policy
|
|
60
72
|
|
package/bin/truthspine-mcp.mjs
CHANGED
|
@@ -26,12 +26,15 @@ try {
|
|
|
26
26
|
startupStage = "installed-runtime-start";
|
|
27
27
|
process.env.TRUTHSPINE_RUNTIME_REGISTRATION_PATH = contract.defaultRuntimeRegistrationPath();
|
|
28
28
|
connection = await launcher.startInstalledMcpLauncherWithRecovery({ signal: controller.signal });
|
|
29
|
-
const result = await connection.closed;
|
|
30
|
-
|
|
31
|
-
} catch {
|
|
29
|
+
if (!connection?.inPlace) { const result = await connection.closed; process.exitCode = result.code; }
|
|
30
|
+
} catch (error) {
|
|
32
31
|
const guidance = startupStage === "workspace-binding"
|
|
33
32
|
? "Start it inside your project folder or pass --project <folder>. Licensing has not been checked; do not sign in or purchase to resolve this."
|
|
34
33
|
: "Install and open the TruthSpine app, connect this project in it, and check --host names your tool. This startup error alone does not establish a licensing failure.";
|
|
35
|
-
|
|
34
|
+
const said = (value, most) => String(value ?? "").replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/(?:[A-Za-z]:)?[\\/][^\s'"`]+/g, "[path]").trim().slice(0, most);
|
|
35
|
+
const where = String(error?.stack ?? "").split("\n").slice(1).map(frame => /at (?:async )?([\w.$<>]+) \(.*?([\w.-]+):(\d+):\d+\)$/.exec(frame.trim())).filter(Boolean).slice(0, 3).map(([, name, file, line]) => `${name} ${file}:${line}`).join(" < ");
|
|
36
|
+
const words = /purchase is not affected/i.test(String(error?.message ?? "")) ? "" : said(error?.message ?? error, 400);
|
|
37
|
+
const cause = [said(error?.code, 80), words, where && `at ${where}`].filter(Boolean).join(": ");
|
|
38
|
+
process.stderr.write(`TruthSpine startup failed at ${startupStage}. ${guidance}${cause ? ` Cause: ${cause}` : ""}\n`);
|
|
36
39
|
process.exitCode = 1;
|
|
37
40
|
} finally { process.removeListener("SIGINT", stop); process.removeListener("SIGTERM", stop); }
|
|
@@ -16,7 +16,7 @@ function buildClaudeSessionStartRequest(input, { homeDir, platform = process.pla
|
|
|
16
16
|
|| event.session_id.length > 2048 || /[\u0000-\u001f\u007f]/.test(event.session_id)) fail();
|
|
17
17
|
const { projectRoot } = launcher.resolveBoundedHostWorkspace({ cwd: event.cwd, homeDir, platform });
|
|
18
18
|
return Object.freeze({
|
|
19
|
-
tool: "
|
|
19
|
+
tool: "onboard_chat",
|
|
20
20
|
// Host/workspace are fixed by the transport, not overridable tool selectors.
|
|
21
21
|
// Older catalogs reject identityMode before tools/call, preventing silent
|
|
22
22
|
// fallback to the old per-attachment/title-derived chat identity.
|
|
@@ -28,7 +28,7 @@ function buildClaudeSessionStartRequest(input, { homeDir, platform = process.pla
|
|
|
28
28
|
|
|
29
29
|
function formatClaudeSessionStartResult(request, result) {
|
|
30
30
|
const metrics = result?.metrics;
|
|
31
|
-
if (!request || request.tool !== "
|
|
31
|
+
if (!request || request.tool !== "onboard_chat"
|
|
32
32
|
|| result?.project?.projectRoot !== request.projectRoot
|
|
33
33
|
|| result?.binding?.hostSurface !== "claude-code"
|
|
34
34
|
|| result?.binding?.hostChatIdHash !== request.sessionIdHash
|
|
@@ -36,19 +36,31 @@ function formatClaudeSessionStartResult(request, result) {
|
|
|
36
36
|
|| typeof metrics?.label !== "string" || !/^[a-z0-9][a-z0-9_-]{0,255}$/.test(metrics.label)
|
|
37
37
|
|| metrics.runId !== metrics.label || metrics.sourceRef !== metrics.label
|
|
38
38
|
|| !/^[a-f0-9]{32}$/.test(metrics.chatSessionFingerprint || "")) fail();
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
39
|
+
const identity = [
|
|
40
|
+
"TruthSpine has returned this session's workspace-bound attachment identity.",
|
|
41
|
+
`For an explicit Attach TruthSpine request in this same host session/workspace, call attach with these exact arguments: ${JSON.stringify(request.arguments)}. Never replace them with an empty object or a guessed title/ID.`,
|
|
42
|
+
"Consult answer_guard before project-dependent answers and actions. Save meaningful work with record; do not copy another chat's identity.",
|
|
43
|
+
"These identifiers provide attribution, not owner authorization or proof that reported work is correct."
|
|
44
|
+
].join("\n");
|
|
45
|
+
return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: withProjectPacket(identity, result?.packet?.text) } };
|
|
46
|
+
}
|
|
47
|
+
// The session starts with the project itself (B2, Brain 2026-11-01: "every new session starts already knowing my project"
|
|
48
|
+
// was true nowhere, since the hook gave only the identity): the connect reply's packet follows the identity lines. Claude
|
|
49
|
+
// Code keeps a hook's text to 10,000 characters and shows only a short preview of anything longer, so the whole text stays
|
|
50
|
+
// under HOOK_CONTEXT_LIMIT, the packet cut at a whole line with a pointer to the rest.
|
|
51
|
+
const HOOK_CONTEXT_LIMIT = 9500;
|
|
52
|
+
function withProjectPacket(identity, packet) {
|
|
53
|
+
const text = typeof packet === "string" ? packet.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").trim() : "";
|
|
54
|
+
if (!text) return identity;
|
|
55
|
+
const head = `${identity}\n\nThis project, as TruthSpine keeps it now:\n`;
|
|
56
|
+
const rest = "\n(Shortened here; consult answer_guard for the rest.)";
|
|
57
|
+
if (head.length + text.length <= HOOK_CONTEXT_LIMIT) return head + text;
|
|
58
|
+
let kept = "";
|
|
59
|
+
for (const line of text.split("\n")) {
|
|
60
|
+
if (head.length + kept.length + line.length + 1 + rest.length > HOOK_CONTEXT_LIMIT) break;
|
|
61
|
+
kept += `${kept ? "\n" : ""}${line}`;
|
|
62
|
+
}
|
|
63
|
+
return kept ? head + kept + rest : identity;
|
|
52
64
|
}
|
|
53
65
|
// Where the installed app records itself (the same place the app writes it). Used when a
|
|
54
66
|
// public connector carries no machine-specific path; an explicit setting still wins.
|
|
@@ -23,6 +23,8 @@ const fail = () => { throw new Error(ERROR); };
|
|
|
23
23
|
const LAUNCHER_RESULTS = Object.freeze({
|
|
24
24
|
app_not_installed: "TruthSpine isn't installed on this computer.",
|
|
25
25
|
app_not_running: "TruthSpine is installed but not running. Open it and try again.",
|
|
26
|
+
app_not_answering: "TruthSpine is installed but did not answer. Open the TruthSpine app and try again.",
|
|
27
|
+
app_not_opened: "TruthSpine is installed but has not been opened yet. Open TruthSpine once, then try again.",
|
|
26
28
|
unavailable: ERROR
|
|
27
29
|
});
|
|
28
30
|
const LAUNCHER_STORE_LINKS = Object.freeze({ win32: "https://apps.microsoft.com/detail/9pbn25k75vdj", darwin: "https://apps.apple.com/us/app/truthspine/id6794478470?mt=12" });
|
|
@@ -36,13 +38,27 @@ const typedCode = error => {
|
|
|
36
38
|
// The one line a person should read when the launcher cannot connect.
|
|
37
39
|
function describeLauncherFailure(error, platform) {
|
|
38
40
|
const code = typedCode(error) || "unavailable";
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
+
const where = platform || process.platform;
|
|
42
|
+
const link = code === "app_not_installed" ? LAUNCHER_STORE_LINKS[where] : undefined;
|
|
43
|
+
// (truthspine.app on every platform, and Linux its own page: B8)
|
|
44
|
+
const get = code === "app_not_installed" ? " Get it here: " + (link ? link + " or " + DOWNLOAD_PAGE : LAUNCHER_GET_LINKS[where] || DOWNLOAD_PAGE) : "";
|
|
45
|
+
return { code, message: LAUNCHER_RESULTS[code] + get, storeLink: link };
|
|
41
46
|
}
|
|
42
47
|
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
43
48
|
// "continue" stays accepted so an entry an earlier version wrote keeps starting; TruthSpine no longer offers or writes it
|
|
44
49
|
// (Continue retired by the owner, 2026-10-09).
|
|
45
50
|
const HOST_IDS = new Set(["claude-desktop", "claude-code", "codex-cli", "cursor", "vscode", "github-copilot-vscode", "continue", "cline", "cline-cli", "windsurf-devin", "opencode", "opencode-ide", "lm-studio", "kiro", "antigravity", "jetbrains", "zed", "hermes-agent", "openclaw", "copilot-cli", "droid", "pi", "computer-agent", "gemini-cli"]);
|
|
51
|
+
// The smaller tool set each app gets; every app not named here gets the full catalog. Nothing falls into a smaller set by
|
|
52
|
+
// default: Claude Code did, from the copilot fallback (2026-09-08) and its joining the host list (2026-09-24), and a
|
|
53
|
+
// shipped package refused its session_update (Brain 2026-10-29).
|
|
54
|
+
const HOST_TOOL_PROFILES = Object.freeze({
|
|
55
|
+
"claude-desktop": "claude-desktop",
|
|
56
|
+
"lm-studio": "lm-studio",
|
|
57
|
+
opencode: "local-agent", "hermes-agent": "local-agent", openclaw: "local-agent", droid: "local-agent", pi: "local-agent",
|
|
58
|
+
vscode: "copilot", // VS Code's own sync command carries its session updates, not this connection
|
|
59
|
+
"github-copilot-vscode": "copilot", // the same VS Code extension and sync command
|
|
60
|
+
"copilot-cli": "copilot" // named so since July (surface-connector-runtime.js)
|
|
61
|
+
});
|
|
46
62
|
function absolute(value, paths) {
|
|
47
63
|
if (typeof value !== "string" || !value.trim() || value !== value.trim()
|
|
48
64
|
|| /[\u0000-\u001f\u007f]/.test(value) || !paths.isAbsolute(value)) fail();
|
|
@@ -154,16 +170,42 @@ function systemTempFolder(root, platform, env) {
|
|
|
154
170
|
if (platform === process.platform) { try { named.push(require("node:os").tmpdir()); } catch { /* no temporary folder known */ } }
|
|
155
171
|
return named.some(base => typeof base === "string" && paths.isAbsolute(base) && equal(root, paths.resolve(base), platform));
|
|
156
172
|
}
|
|
173
|
+
// TruthSpine's own throwaway folders (Brain 2026-10-28): a folder named truthspine-* in a temporary folder, and every
|
|
174
|
+
// folder inside one, is made by TruthSpine's tests and checks, never by the person; one test run on the owner PC
|
|
175
|
+
// (2026-09-27) left twenty such projects' data in the person's own TruthSpine home. A TruthSpine home that itself lives
|
|
176
|
+
// in a temporary folder (a test's or a check's own) keeps them as projects.
|
|
177
|
+
function temporaryFolders(platform, env) {
|
|
178
|
+
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
179
|
+
const folders = platform === "win32" ? [env.TEMP, env.TMP, env.LOCALAPPDATA && paths.join(env.LOCALAPPDATA, "Temp")]
|
|
180
|
+
: [env.TMPDIR, "/tmp", "/var/tmp", "/private/tmp", "/private/var/tmp", ...(platform === "darwin" ? ["/var/folders", "/private/var/folders"] : [])];
|
|
181
|
+
if (platform === process.platform) {
|
|
182
|
+
try { folders.push(require("node:os").tmpdir()); } catch { /* no temporary folder known */ }
|
|
183
|
+
const account = platform === "win32" ? accountHomeFolder() : null;
|
|
184
|
+
if (account) folders.push(paths.join(account, "AppData", "Local", "Temp"));
|
|
185
|
+
}
|
|
186
|
+
return folders.filter(folder => typeof folder === "string" && paths.isAbsolute(folder)).map(folder => paths.resolve(folder));
|
|
187
|
+
}
|
|
188
|
+
function throwawayFolder(root, homeDir, platform, env) {
|
|
189
|
+
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
190
|
+
const temps = temporaryFolders(platform, env);
|
|
191
|
+
let made = false;
|
|
192
|
+
for (let folder = root; !made && paths.dirname(folder) !== folder; folder = paths.dirname(folder)) {
|
|
193
|
+
made = /^truthspine-/i.test(paths.basename(folder)) && temps.some(temp => equal(paths.dirname(folder), temp, platform));
|
|
194
|
+
}
|
|
195
|
+
const truthHome = env.TRUTHSPINE_HOME || (typeof homeDir === "string" && paths.isAbsolute(homeDir) ? paths.join(homeDir, ".truthspine") : null);
|
|
196
|
+
if (!made || typeof truthHome !== "string" || !paths.isAbsolute(truthHome)) return false;
|
|
197
|
+
return !temps.some(temp => insideFolder(paths.resolve(truthHome), temp, platform));
|
|
198
|
+
}
|
|
157
199
|
// Judged by the path alone (a sandboxed relay may not look inside the folder): the disk root, a home folder (the one
|
|
158
|
-
// given and the account's own), a system or program folder, the temporary folder itself,
|
|
159
|
-
// never a project.
|
|
200
|
+
// given and the account's own), a system or program folder, the temporary folder itself, TruthSpine's own throwaway
|
|
201
|
+
// folders, or an AI app's own folder is never a project.
|
|
160
202
|
function neverAProjectFolder(folder, { homeDir, platform = process.platform, env = process.env } = {}) {
|
|
161
203
|
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
162
204
|
if (typeof folder !== "string" || !paths.isAbsolute(folder)) return true;
|
|
163
205
|
// (also for a saved project: the start-of-app tidy then removes a "MICROS~1" project an earlier version made)
|
|
164
206
|
const root = longWindowsName(paths.resolve(folder), platform);
|
|
165
207
|
if (equal(root, paths.parse(root).root, platform) || systemOrProgramFolder(root, platform, env || {})
|
|
166
|
-
|| systemTempFolder(root, platform, env || {})) return true;
|
|
208
|
+
|| systemTempFolder(root, platform, env || {}) || throwawayFolder(root, homeDir, platform, env || {})) return true;
|
|
167
209
|
const homes = [homeDir, platform === process.platform ? accountHomeFolder() : null].filter(home => typeof home === "string" && paths.isAbsolute(home));
|
|
168
210
|
return homes.some(home => equal(root, paths.resolve(home), platform) || appOwnFolder(root, home, platform));
|
|
169
211
|
}
|
|
@@ -203,10 +245,30 @@ function restrictiveTelemetryEnv(...sources) {
|
|
|
203
245
|
}) ? { TRUTHSPINE_TELEMETRY_MODE: "disabled" } : {};
|
|
204
246
|
}
|
|
205
247
|
|
|
248
|
+
// Whether TruthSpine's own files are where its installer puts them (Brain 2026-11-01, B8): a copy installed but never opened
|
|
249
|
+
// has written no install record yet, and is told to open TruthSpine once rather than to get it. Names only, nothing run:
|
|
250
|
+
// the Store's command in WindowsApps, the app in Applications, the snap's or the system package's command.
|
|
251
|
+
function installedAppPresent({ platform = process.platform, env = process.env, homeDir = env.HOME || env.USERPROFILE || "",
|
|
252
|
+
exists = file => fs.existsSync(file), names = folder => fs.readdirSync(folder) } = {}) {
|
|
253
|
+
try {
|
|
254
|
+
if (platform === "win32") {
|
|
255
|
+
// (the execution alias is listed, never read: reading it may fail while starting it works)
|
|
256
|
+
const folder = typeof env.LOCALAPPDATA === "string" && path.win32.isAbsolute(env.LOCALAPPDATA) ? path.win32.join(env.LOCALAPPDATA, "Microsoft", "WindowsApps") : null;
|
|
257
|
+
return Boolean(folder) && names(folder).some(name => name.toLowerCase() === "truthspine-runtime.exe");
|
|
258
|
+
}
|
|
259
|
+
if (platform === "darwin") return ["/Applications/TruthSpine.app", homeDir ? path.posix.join(homeDir, "Applications", "TruthSpine.app") : null].some(file => file && exists(file));
|
|
260
|
+
if (platform === "linux") return Object.values(LINUX_PACKAGE_COMMANDS).some(file => exists(file));
|
|
261
|
+
} catch { /* not found */ }
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
206
265
|
function readRegistration(filename) {
|
|
207
266
|
let fd;
|
|
208
267
|
try {
|
|
209
|
-
|
|
268
|
+
// (no record at all: TruthSpine has never been installed and opened on this computer, B8; or installed, never opened)
|
|
269
|
+
let before;
|
|
270
|
+
try { before = fs.lstatSync(filename); }
|
|
271
|
+
catch (error) { if (error && error.code === "ENOENT") typedFail(installedAppPresent() ? "app_not_opened" : "app_not_installed"); fail(); }
|
|
210
272
|
if (!before.isFile() || before.isSymbolicLink() || before.size > 65536) fail();
|
|
211
273
|
fd = fs.openSync(filename, "r");
|
|
212
274
|
const opened = fs.fstatSync(fd);
|
|
@@ -218,17 +280,23 @@ function readRegistration(filename) {
|
|
|
218
280
|
const parsed = JSON.parse(buffer.subarray(0, used).toString("utf8"));
|
|
219
281
|
if (!object(parsed) || parsed.schema !== "truthspine.installed-runtime.v1") fail();
|
|
220
282
|
return parsed;
|
|
221
|
-
} catch { fail(); }
|
|
283
|
+
} catch (error) { if (["app_not_installed", "app_not_opened"].includes(typedCode(error))) throw error; fail(); }
|
|
222
284
|
finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
223
285
|
}
|
|
224
286
|
|
|
225
287
|
const WINDOWS_OS_NAMES = new Set(["systemroot", "windir", "comspec", "temp", "tmp", "userprofile", "localappdata", "appdata",
|
|
226
288
|
"programfiles", "programfiles(x86)", "programdata", "allusersprofile", "public", "processor_architecture",
|
|
227
|
-
"number_of_processors", "computername", "os", "pathext", "path"]);
|
|
289
|
+
"number_of_processors", "computername", "os", "pathext", "path", "tz"]);
|
|
228
290
|
// Linux: what the AppImage runtime and the server need to find the user's folders, and nothing else.
|
|
291
|
+
// (TZ on both: a chat server reads the day in the zone the app reads it in, Brain 2026-10-30)
|
|
229
292
|
const LINUX_OS_NAMES = new Set(["home", "path", "user", "logname", "lang", "lc_all", "lc_ctype", "lc_messages", "tmpdir",
|
|
230
|
-
"xdg_data_home", "xdg_config_home", "xdg_cache_home", "xdg_runtime_dir", "appimage_extract_and_run"]);
|
|
293
|
+
"xdg_data_home", "xdg_config_home", "xdg_cache_home", "xdg_runtime_dir", "appimage_extract_and_run", "tz"]);
|
|
231
294
|
const LINUX_APP_ROOT = "@app";
|
|
295
|
+
// The fixed launchers of the Linux packages that have one (src/packaged-platform.cjs keeps the same table; this file ships
|
|
296
|
+
// alone in the VS Code extension, so it keeps its own copy, and a test keeps the two the same). The snap's follows store
|
|
297
|
+
// updates; a versioned /snap/truthspine/<revision>/ path is never accepted.
|
|
298
|
+
const LINUX_PACKAGE_COMMANDS = Object.freeze({ "linux-snap": "/snap/bin/truthspine", "linux-system": "/usr/bin/truthspine" });
|
|
299
|
+
const linuxPackageCommand = name => (typeof name === "string" && Object.prototype.hasOwnProperty.call(LINUX_PACKAGE_COMMANDS, name) ? LINUX_PACKAGE_COMMANDS[name] : null);
|
|
232
300
|
function installedChildEnv({ env, platform, profile, root, hostId, developmentBuild = false }) {
|
|
233
301
|
const childEnv = {};
|
|
234
302
|
const osNames = platform === "linux" ? LINUX_OS_NAMES : WINDOWS_OS_NAMES;
|
|
@@ -245,12 +313,9 @@ function installedChildEnv({ env, platform, profile, root, hostId, developmentBu
|
|
|
245
313
|
...(root && env.TRUTHSPINE_PROJECT_FROM_CWD === "1" ? { TRUTHSPINE_FOLLOWED_FOLDER: "1" } : {}),
|
|
246
314
|
TRUTHSPINE_HOST_ID: hostId,
|
|
247
315
|
TRUTHSPINE_MCP_OUTPUT_FRAMING: "jsonl" });
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
if (!["codex-cli", "cursor", "kiro", "antigravity", "jetbrains", "zed"].includes(hostId)) childEnv.TRUTHSPINE_MCP_TOOL_PROFILE = hostId === "claude-desktop"
|
|
252
|
-
? "claude-desktop"
|
|
253
|
-
: hostId === "lm-studio" ? "lm-studio" : ["opencode", "hermes-agent", "openclaw", "droid", "pi"].includes(hostId) ? "local-agent" : "copilot";
|
|
316
|
+
// Host identity is a policy selector, not authentication or permission to broaden another host's advertised tool
|
|
317
|
+
// profile. An app HOST_TOOL_PROFILES does not name keeps the full catalog, including session_update.
|
|
318
|
+
if (Object.hasOwn(HOST_TOOL_PROFILES, hostId)) childEnv.TRUTHSPINE_MCP_TOOL_PROFILE = HOST_TOOL_PROFILES[hostId];
|
|
254
319
|
// Preserve an existing restrictive opt-out, never an ambient opt-in or
|
|
255
320
|
// telemetry identity. Dropping an opt-out could enable collection when the
|
|
256
321
|
// registered profile has consent. This grants no product access.
|
|
@@ -283,13 +348,15 @@ function buildInstalledMcpLaunchPlan({ registration, registrationPath, projectRo
|
|
|
283
348
|
socketPath: paths.join(dataRoot, ".ts", `mcp-${hash}.sock`) });
|
|
284
349
|
}
|
|
285
350
|
if (platform === "linux") {
|
|
286
|
-
// Linux
|
|
287
|
-
// under /tmp at a new path on every launch,
|
|
288
|
-
//
|
|
351
|
+
// Linux packaged lane (P12). The AppImage file, or the package's fixed launcher (the snap's /snap/bin/truthspine), is
|
|
352
|
+
// the one stable path: the app inside an AppImage is mounted under /tmp at a new path on every launch, and a snap's
|
|
353
|
+
// folder changes with each update, so no path inside the package is trusted. The package's own entry point (AppRun,
|
|
354
|
+
// the snap's truthspine-launch) turns a leading "@app/" argument into a path inside it.
|
|
289
355
|
const provenance = registration.runtimeProvenance;
|
|
290
356
|
const command = absolute(registration.command, paths);
|
|
291
|
-
|
|
292
|
-
|
|
357
|
+
const fixed = object(provenance) ? linuxPackageCommand(provenance.packagedPlatform) : null;
|
|
358
|
+
if (!object(provenance) || provenance.packaged !== true
|
|
359
|
+
|| !(provenance.packagedPlatform === "linux-appimage" ? /[.]AppImage$/.test(paths.basename(command)) : fixed !== null && command === fixed)
|
|
293
360
|
|| !equal(absolute(provenance.appExecutable, paths), command, platform)) fail();
|
|
294
361
|
return Object.freeze({ mode: "installed-stdio", platform, hostId, packageRoot: LINUX_APP_ROOT, command,
|
|
295
362
|
args: Object.freeze([`${LINUX_APP_ROOT}/src/mcp-server.js`]), cwd: root ?? profile, projectRoot: root, truthHome: profile,
|
|
@@ -512,7 +579,8 @@ function inPlaceHandover(plan, { platform = process.platform, execPath = process
|
|
|
512
579
|
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
513
580
|
let serverPath;
|
|
514
581
|
if (platform === "linux") {
|
|
515
|
-
|
|
582
|
+
const sameAppImage = typeof env.APPIMAGE === "string" && paths.isAbsolute(env.APPIMAGE) && equal(paths.resolve(env.APPIMAGE), plan.command, platform);
|
|
583
|
+
if (!sameAppImage && linuxPackageCommand(env.TRUTHSPINE_PACKAGED_PLATFORM) !== plan.command) return null;
|
|
516
584
|
if (plan.args[0] !== `${LINUX_APP_ROOT}/src/mcp-server.js`) return null;
|
|
517
585
|
serverPath = paths.join(paths.dirname(execPath), "resources", "app", "src", "mcp-server.js");
|
|
518
586
|
} else {
|
|
@@ -535,10 +603,8 @@ async function handOverInPlace(handover, { importer = file => import(require("no
|
|
|
535
603
|
return { inPlace: true };
|
|
536
604
|
}
|
|
537
605
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const delegated = relayDelegationPlan();
|
|
541
|
-
if (delegated) return relayInstalledMcp(delegated);
|
|
606
|
+
// The installed copy's plan, validated; Windows tries the Store package again when the recorded one is gone.
|
|
607
|
+
async function installedPlan(signal) {
|
|
542
608
|
const options = installedLauncherOptions();
|
|
543
609
|
let plan = buildInstalledMcpLaunchPlan(options);
|
|
544
610
|
try { validateInstalledLaunchPlan(plan); }
|
|
@@ -546,11 +612,193 @@ async function startInstalledMcpLauncherWithRecovery({ signal, inPlace = handOve
|
|
|
546
612
|
if (plan.platform !== 'win32') throw error;
|
|
547
613
|
plan = (await prepareRecoveredMcpPlan(options, { signal })).plan;
|
|
548
614
|
}
|
|
615
|
+
return plan;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// With no TruthSpine to reach (B8, Brain 2026-11-01): the connection stays up and answers by itself, so the chat can say
|
|
619
|
+
// "TruthSpine isn't installed yet. Get it here: <link>" instead of the app showing a server that failed. The page opens
|
|
620
|
+
// only when the chat calls open_download_page after the person says yes, and once TruthSpine is there the next tool call
|
|
621
|
+
// connects this same chat to it (the person's first message is passed on, and the app is told the tools changed).
|
|
622
|
+
// A copy that starts but does not answer within NO_ANSWER_MS is given up the same way.
|
|
623
|
+
const NO_ANSWER_MS = 10000;
|
|
624
|
+
const LAUNCHER_GET_LINKS = Object.freeze({ ...LAUNCHER_STORE_LINKS, linux: "https://truthspine.app/download/linux" });
|
|
625
|
+
const DOWNLOAD_PAGE = "https://truthspine.app/download";
|
|
626
|
+
function noAppLines(code, platform) {
|
|
627
|
+
const link = LAUNCHER_GET_LINKS[platform] || DOWNLOAD_PAGE;
|
|
628
|
+
if (code === "app_not_installed") return { link, say: `TruthSpine isn't installed yet. Get it here: ${link}`,
|
|
629
|
+
next: "Ask the person whether to open this page now, and call open_download_page only if they say yes. Once TruthSpine is installed and open, call attach again: this chat then connects by itself." };
|
|
630
|
+
const say = code === "app_not_opened" ? "Open TruthSpine once. It is installed on this computer but has not been opened yet, so chats can't reach it."
|
|
631
|
+
: code === "app_not_running" ? "TruthSpine is installed but not running. Open the TruthSpine app, then ask again."
|
|
632
|
+
: "TruthSpine is installed but did not answer. Open the TruthSpine app, then ask again.";
|
|
633
|
+
return { link: null, say, next: "Once it is open, call attach again: this chat then connects by itself." };
|
|
634
|
+
}
|
|
635
|
+
function openPage(url, platform, spawnImpl = spawn) {
|
|
636
|
+
const [command, args] = platform === "win32" ? ["explorer.exe", [url]] : platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
637
|
+
const child = spawnImpl(command, args, { detached: true, stdio: "ignore", windowsHide: true, shell: false });
|
|
638
|
+
child.on("error", () => {}); child.unref();
|
|
639
|
+
}
|
|
640
|
+
// Newline-framed JSON-RPC lines from a stream (MCP over standard input and output).
|
|
641
|
+
function readLines(stream, onLine) {
|
|
642
|
+
let pending = "";
|
|
643
|
+
const onData = chunk => {
|
|
644
|
+
pending += chunk.toString("utf8");
|
|
645
|
+
let end;
|
|
646
|
+
while ((end = pending.indexOf("\n")) !== -1) { const line = pending.slice(0, end).replace(/\r$/, ""); pending = pending.slice(end + 1); if (line.trim()) onLine(line); }
|
|
647
|
+
};
|
|
648
|
+
stream.on("data", onData);
|
|
649
|
+
return () => stream.removeListener("data", onData);
|
|
650
|
+
}
|
|
651
|
+
const parse = line => { try { const message = JSON.parse(line); return object(message) ? message : null; } catch { return null; } };
|
|
652
|
+
|
|
653
|
+
// Relays to the installed copy, and gives it up when it does not answer the app's first request in time (onSilent gets the
|
|
654
|
+
// lines it was sent). Lines come in through send(); the copy's answers go to output, the swallowed id's answer excepted.
|
|
655
|
+
function answeredRelay(plan, { output, waitMs = NO_ANSWER_MS, onSilent, onAnswered = () => {}, swallowId = null, relay = relayInstalledMcp }) {
|
|
656
|
+
const { PassThrough } = require("node:stream");
|
|
657
|
+
const toCopy = new PassThrough(), fromCopy = new PassThrough();
|
|
658
|
+
const sent = [];
|
|
659
|
+
let waitingFor = undefined, answered = false, gaveUp = false, timer = null;
|
|
660
|
+
const connection = relay(plan, { input: toCopy, output: fromCopy });
|
|
661
|
+
function giveUp() {
|
|
662
|
+
if (gaveUp) return;
|
|
663
|
+
gaveUp = true; clearTimeout(timer); stopReading(); connection.stop(1);
|
|
664
|
+
onSilent(sent.slice());
|
|
665
|
+
}
|
|
666
|
+
const stopReading = readLines(fromCopy, line => {
|
|
667
|
+
if (gaveUp) return;
|
|
668
|
+
const message = parse(line);
|
|
669
|
+
if (message && !answered && waitingFor !== undefined && message.id === waitingFor) { answered = true; clearTimeout(timer); onAnswered(); }
|
|
670
|
+
if (message && swallowId !== null && message.id === swallowId) return;
|
|
671
|
+
output.write(line + "\n");
|
|
672
|
+
});
|
|
673
|
+
// (a copy that ends before it answered is given up too)
|
|
674
|
+
connection.closed.then(result => { if (!answered && !gaveUp && result.code !== 0) giveUp(); });
|
|
675
|
+
return {
|
|
676
|
+
connection,
|
|
677
|
+
send(line) {
|
|
678
|
+
if (gaveUp) return false;
|
|
679
|
+
// (the wait starts with the app's first request: an app may start the connection long before it asks anything)
|
|
680
|
+
if (waitingFor === undefined) {
|
|
681
|
+
const message = parse(line);
|
|
682
|
+
if (message && message.id !== undefined && message.id !== null) { waitingFor = message.id; timer = setTimeout(() => { if (!answered) giveUp(); }, waitMs); }
|
|
683
|
+
}
|
|
684
|
+
if (!answered) sent.push(line);
|
|
685
|
+
toCopy.write(line + "\n");
|
|
686
|
+
return true;
|
|
687
|
+
},
|
|
688
|
+
end() { if (!gaveUp) { clearTimeout(timer); toCopy.end(); } },
|
|
689
|
+
gaveUp: () => gaveUp
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function serveWithoutApp(code, { input = process.stdin, output = process.stdout, platform = process.platform, attempt,
|
|
694
|
+
waitMs = NO_ANSWER_MS, spawnImpl = spawn, retryEveryMs = 10000, now = () => Date.now(), relay = relayInstalledMcp, earlier = [] } = {}) {
|
|
695
|
+
let resolveClosed, live = null, init = null, lastTry = -Infinity, trying = null, done = false;
|
|
696
|
+
const closed = new Promise(resolve => { resolveClosed = resolve; });
|
|
697
|
+
const write = message => output.write(JSON.stringify(message) + "\n");
|
|
698
|
+
const lines = () => noAppLines(code, platform);
|
|
699
|
+
const finish = codeOut => { if (done) return; done = true; stopReading(); input.removeListener("end", ended); resolveClosed({ code: codeOut, pid: null, cleanupConfirmed: true }); };
|
|
700
|
+
const ended = () => { if (live) { live.end(); live.connection.closed.then(result => finish(result.code)); } else finish(0); };
|
|
701
|
+
const tools = () => [{ name: "attach", description: `Connect TruthSpine. ${lines().say}`, inputSchema: { type: "object", properties: {}, additionalProperties: true } }]
|
|
702
|
+
.concat(code === "app_not_installed" ? [{ name: "open_download_page", description: "Open the TruthSpine download page in the browser. Call it only after the person says yes.",
|
|
703
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false } }] : []);
|
|
704
|
+
const answer = message => {
|
|
705
|
+
const { method, id } = message;
|
|
706
|
+
const reply = result => { if (id !== undefined && id !== null) write({ jsonrpc: "2.0", id, result }); };
|
|
707
|
+
if (method === "initialize") {
|
|
708
|
+
init = message;
|
|
709
|
+
return reply({ protocolVersion: (message.params && message.params.protocolVersion) || "2025-06-18", capabilities: { tools: { listChanged: true } },
|
|
710
|
+
serverInfo: { name: "truthspine", version: "not-connected" }, instructions: `${lines().say} ${lines().next}` });
|
|
711
|
+
}
|
|
712
|
+
if (method === "tools/list") return reply({ tools: tools() });
|
|
713
|
+
if (method === "resources/list") return reply({ resources: [] });
|
|
714
|
+
if (method === "prompts/list") return reply({ prompts: [] });
|
|
715
|
+
if (method === "ping") return reply({});
|
|
716
|
+
if (method === "tools/call") {
|
|
717
|
+
const name = message.params && message.params.name;
|
|
718
|
+
if (name === "open_download_page" && code === "app_not_installed") {
|
|
719
|
+
try { openPage(lines().link, platform, spawnImpl); return reply({ content: [{ type: "text", text: `Opened ${lines().link}. ${lines().next}` }] }); }
|
|
720
|
+
catch { return reply({ content: [{ type: "text", text: `Could not open the page. The link is ${lines().link}.` }] }); }
|
|
721
|
+
}
|
|
722
|
+
return reply({ content: [{ type: "text", text: `${lines().say} ${lines().next}` }] });
|
|
723
|
+
}
|
|
724
|
+
if (typeof method === "string" && method.indexOf("notifications/") === 0) return undefined;
|
|
725
|
+
if (id !== undefined && id !== null) write({ jsonrpc: "2.0", id, error: { code: -32601, message: lines().say } });
|
|
726
|
+
return undefined;
|
|
727
|
+
};
|
|
728
|
+
// Once TruthSpine is there: the copy gets the app's first request under its own id (its answer is not passed on, the app
|
|
729
|
+
// has one already), the initialized notice, then the waiting call; the app hears that the tools changed.
|
|
730
|
+
async function connect(callLine) {
|
|
731
|
+
let plan;
|
|
732
|
+
try { plan = await attempt(); } catch (error) { const next = typedCode(error); if (next && next !== code && next !== "unavailable") code = next; return false; }
|
|
733
|
+
if (done) return false;
|
|
734
|
+
const handoverId = `truthspine-handover-${crypto.randomUUID()}`;
|
|
735
|
+
let copy = null;
|
|
736
|
+
const outcome = new Promise(resolve => {
|
|
737
|
+
copy = answeredRelay(plan, { output, waitMs, relay, swallowId: handoverId, onAnswered: () => resolve("answered"),
|
|
738
|
+
onSilent: () => { code = "app_not_answering"; resolve("silent"); } });
|
|
739
|
+
});
|
|
740
|
+
const first = init ? { ...init, id: handoverId } : { jsonrpc: "2.0", id: handoverId, method: "initialize",
|
|
741
|
+
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "truthspine-launcher", version: "1" } } };
|
|
742
|
+
copy.send(JSON.stringify(first));
|
|
743
|
+
if (await outcome !== "answered" || done) { if (!copy.gaveUp()) copy.connection.stop(1); return false; }
|
|
744
|
+
copy.send(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }));
|
|
745
|
+
live = copy;
|
|
746
|
+
copy.connection.closed.then(result => finish(result.code));
|
|
747
|
+
write({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
|
|
748
|
+
live.send(callLine);
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
751
|
+
const onLine = line => {
|
|
752
|
+
if (live) { live.send(line); return; }
|
|
753
|
+
const message = parse(line);
|
|
754
|
+
if (!message) return;
|
|
755
|
+
if (message.method === "tools/call" && !trying && now() - lastTry >= retryEveryMs && !(message.params && message.params.name === "open_download_page")) {
|
|
756
|
+
lastTry = now();
|
|
757
|
+
trying = connect(line).then(connected => { trying = null; if (!connected) answer(message); }, () => { trying = null; answer(message); });
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (trying) { trying.then(() => onLine(line)); return; }
|
|
761
|
+
answer(message);
|
|
762
|
+
};
|
|
763
|
+
for (const line of earlier) onLine(line);
|
|
764
|
+
const stopReading = readLines(input, onLine);
|
|
765
|
+
input.once("end", ended);
|
|
766
|
+
if (input.readableEnded) ended();
|
|
767
|
+
return { closed, stop: codeOut => finish(codeOut || 0), pid: null, failure: () => code, withoutApp: true };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async function startInstalledMcpLauncherWithRecovery({ signal, inPlace = handOverInPlace, input = process.stdin, output = process.stdout,
|
|
771
|
+
waitMs = NO_ANSWER_MS, relay = relayInstalledMcp } = {}) {
|
|
772
|
+
if (signal?.aborted) fail();
|
|
773
|
+
const delegated = relayDelegationPlan();
|
|
774
|
+
if (delegated) return relayInstalledMcp(delegated);
|
|
775
|
+
let plan;
|
|
776
|
+
try { plan = await installedPlan(signal); }
|
|
777
|
+
catch (error) {
|
|
778
|
+
const code = typedCode(error);
|
|
779
|
+
if (!["app_not_installed", "app_not_opened", "app_not_running"].includes(code)) throw error;
|
|
780
|
+
return serveWithoutApp(code, { input, output, waitMs, relay, attempt: () => installedPlan(signal) });
|
|
781
|
+
}
|
|
549
782
|
if (signal?.aborted) fail();
|
|
550
783
|
// (only a plan that passed validation is ever handed over)
|
|
551
784
|
const handover = inPlaceHandover(plan);
|
|
552
785
|
if (handover) return inPlace(handover);
|
|
553
|
-
return relayInstalledMcp(plan);
|
|
786
|
+
if (plan.mode !== "installed-stdio") return relayInstalledMcp(plan);
|
|
787
|
+
// A copy that does not answer the app's first request in time is given up, and this connection answers by itself.
|
|
788
|
+
let resolveClosed, without = null;
|
|
789
|
+
const closed = new Promise(resolve => { resolveClosed = resolve; });
|
|
790
|
+
let watched;
|
|
791
|
+
const stopReading = readLines(input, line => { if (without) return; watched.send(line); });
|
|
792
|
+
const ended = () => { if (!without) watched.end(); };
|
|
793
|
+
watched = answeredRelay(plan, { output, waitMs, relay, onSilent: sent => {
|
|
794
|
+
stopReading(); input.removeListener("end", ended);
|
|
795
|
+
without = serveWithoutApp("app_not_answering", { input, output, waitMs, relay, attempt: () => installedPlan(signal), earlier: sent });
|
|
796
|
+
without.closed.then(resolveClosed);
|
|
797
|
+
} });
|
|
798
|
+
input.once("end", ended);
|
|
799
|
+
watched.connection.closed.then(result => { if (!watched.gaveUp()) { stopReading(); resolveClosed(result); } });
|
|
800
|
+
return { closed, stop: code => (without ? without.stop(code) : watched.connection.stop(code)), pid: watched.connection.pid,
|
|
801
|
+
failure: () => (without ? without.failure() : watched.connection.failure()) };
|
|
554
802
|
}
|
|
555
803
|
|
|
556
804
|
// Store package discovery stays in this single-file thin transport so generated
|
|
@@ -683,10 +931,10 @@ async function prepareRecoveredInstalledPlan(initial, signal) {
|
|
|
683
931
|
recovered: selected.version !== registered[1] });
|
|
684
932
|
}
|
|
685
933
|
|
|
686
|
-
module.exports = { readRegistration, resolveBoundedHostWorkspace, neverAProjectFolder, scratchFolder, launcherProjectRoot, buildInstalledMcpLaunchPlan, buildInstalledCliLaunchPlan,
|
|
934
|
+
module.exports = { HOST_IDS, HOST_TOOL_PROFILES, LINUX_PACKAGE_COMMANDS, readRegistration, resolveBoundedHostWorkspace, neverAProjectFolder, scratchFolder, launcherProjectRoot, buildInstalledMcpLaunchPlan, buildInstalledCliLaunchPlan,
|
|
687
935
|
validateInstalledLaunchPlan, restrictiveTelemetryEnv, relayInstalledMcp, relayDelegationPlan, startInstalledMcpLauncher,
|
|
688
936
|
startInstalledMcpLauncherWithRecovery, qualifyCurrentStorePackage, discoverCurrentStorePackage, prepareRecoveredMcpPlan, prepareRecoveredCliPlan,
|
|
689
|
-
LAUNCHER_RESULTS, LAUNCHER_STORE_LINKS, describeLauncherFailure, inPlaceHandover, handOverInPlace, IN_PLACE_RUNTIME_KEYS };
|
|
937
|
+
LAUNCHER_RESULTS, LAUNCHER_STORE_LINKS, LAUNCHER_GET_LINKS, installedAppPresent, NO_ANSWER_MS, describeLauncherFailure, serveWithoutApp, answeredRelay, inPlaceHandover, handOverInPlace, IN_PLACE_RUNTIME_KEYS };
|
|
690
938
|
if (require.main === module) {
|
|
691
939
|
const controller = new AbortController();
|
|
692
940
|
let connection;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "truthspine",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Stop re-explaining your project to every new AI chat. MCP connector for TruthSpine: gives AI agents a checked, local record of your project (decisions, work done, failures, next steps). Works on your computer. No cloud account. Needs the TruthSpine app.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": {
|