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 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
- The keeper of your projects. You lead the work. TruthSpine keeps the truth automatically.
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 starts already knowing your project. Your project stays on your computer.
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
+ ![Codex in VS Code is asked what was decided for custom cake orders and answers from TruthSpine's saved record: $39.](https://truthspine.app/media/listing/truthspine-codex-demo.gif)
16
+
17
+ *Codex in VS Code. Real recording, waits shortened.*
18
+
19
+ ![Every new chat makes me explain the entire project again. Every connected chat checks your project before it answers, so you never re-explain it.](https://truthspine.app/media/listing/truthspine-panel-1.png)
20
+
21
+ ![My agents drift onto old plans or the wrong project. Each chat works with its own project, and the current decisions win over old ones.](https://truthspine.app/media/listing/truthspine-panel-2.png)
22
+
23
+ ![Built to work where You work. 19 AI apps on Windows, 18 on Mac and Linux, and cloud agents through Remote access.](https://truthspine.app/media/listing/truthspine-panel-6.png)
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
- - `truthspine_attach`: Attach this chat to the project's TruthSpine record and receive the current brief: decisions, completed work, evidence and next steps.
47
- - `truthspine_consult`: Ask what is true about the project, what was decided and why, or what to do next. Read only.
48
- - `truthspine_record`: Save a decision, finished work, a failure or the next step to the project's record on this computer.
49
- - `truthspine_history`: Read what was saved recently, or confirm that a save was recorded. Read only.
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 free (Apache License 2.0). It needs the TruthSpine app, which you get and buy separately.
54
- Installing the connector does not start a trial or need its own license.
55
- On Windows, start the free 14-day trial in the TruthSpine app. Then buy once in the Microsoft Store: $99.99 at the launch price (regular price $249.99), for V1 and every 1.x update. Reinstalling does not restart the trial.
56
- On Mac, start the free 14-day trial in the TruthSpine app. Then buy once in the App Store: $99.99 at the launch price (regular price $249.99), for V1 and every 1.x update. Restore Purchases brings it back on your other Macs.
57
- On Linux, the TruthSpine AppImage from truthspine.app starts with a free 14-day trial. Then buy once from us: $99.99 at the launch price (regular price $249.99), for V1 and every 1.x update, on up to three of your own Linux computers. You can move it between them in the 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
 
@@ -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
- process.exitCode = result.code;
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
- process.stderr.write(`TruthSpine startup failed at ${startupStage}. ${guidance}\n`);
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: "truthspine_onboard_chat",
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 !== "truthspine_onboard_chat"
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 readScope = { chatId: metrics.label, chatSessionFingerprint: metrics.chatSessionFingerprint };
40
- const syncIdentity = { runId: metrics.label, sourceRef: metrics.label,
41
- chatSessionFingerprint: metrics.chatSessionFingerprint };
42
- return {
43
- hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: [
44
- "TruthSpine has returned this session's workspace-bound attachment identity.",
45
- `Use this readScope for project-dependent TruthSpine reads: ${JSON.stringify(readScope)}.`,
46
- `Use this identity for governed session updates: ${JSON.stringify(syncIdentity)}.`,
47
- `For an explicit Attach TruthSpine request in this same host session/workspace, call truthspine_onboard_chat with these exact arguments: ${JSON.stringify(request.arguments)}. Never replace them with an empty object or a guessed title/ID.`,
48
- "Consult truthspine_answer_guard before project-dependent answers and actions. Persist meaningful work deltas through governed session updates using this same identity; do not copy another chat's identity.",
49
- "These identifiers provide attribution, not owner authorization or proof that reported work is correct."
50
- ].join("\n") }
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 link = code === "app_not_installed" ? LAUNCHER_STORE_LINKS[platform || process.platform] : undefined;
40
- return { code, message: LAUNCHER_RESULTS[code] + (link ? " Install it from " + link : ""), storeLink: link };
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, or an AI app's own folder is
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
- const before = fs.lstatSync(filename);
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
- // Codex retains its existing full catalog, including session_update. Host
249
- // identity is a policy selector, not authentication or permission to broaden
250
- // another host's advertised tool profile.
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 AppImage lane (P12). The AppImage file is the one stable path: the app inside it is mounted
287
- // under /tmp at a new path on every launch, so no mount path is trusted. The AppImage's AppRun turns
288
- // a leading "@app/" argument into a path inside its own mount.
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
- if (!object(provenance) || provenance.packaged !== true || provenance.packagedPlatform !== "linux-appimage"
292
- || !/[.]AppImage$/.test(paths.basename(command))
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
- if (typeof env.APPIMAGE !== "string" || !paths.isAbsolute(env.APPIMAGE) || !equal(paths.resolve(env.APPIMAGE), plan.command, platform)) return null;
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
- async function startInstalledMcpLauncherWithRecovery({ signal, inPlace = handOverInPlace } = {}) {
539
- if (signal?.aborted) fail();
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.1",
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": {