@krimto-labs/krimto 0.2.22 → 0.2.27

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/bin/krimto.mjs CHANGED
@@ -56,6 +56,21 @@ try {
56
56
  const minimal = flags.includes("--minimal");
57
57
  const yes = flags.includes("--yes");
58
58
  const isTty = process.stdin.isTTY === true;
59
+
60
+ // v0.2.24 — fix for the "AI agent runs krimto init and silently gets the legacy writer"
61
+ // gap. When invoked from a non-TTY context (e.g. Claude Code's Bash tool) with no flags,
62
+ // the interactive wizard CAN'T run; the legacy rule-only writer would proceed silently
63
+ // and the caller wouldn't realize MCP wiring + service install were skipped. Print a
64
+ // clear notice up front so the user (or AI relaying the result) knows what just happened.
65
+ if (!isTty && !all && !minimal && !yes) {
66
+ process.stderr.write(
67
+ "ℹ️ No interactive terminal detected — running lightweight init (rules only).\n" +
68
+ " For the FULL setup (editor wiring + service install), either:\n" +
69
+ " • Run from a real terminal: $ npx @krimto-labs/krimto init\n" +
70
+ " • Or pass --yes here: $ npx @krimto-labs/krimto init --yes\n\n",
71
+ );
72
+ }
73
+
59
74
  const legacyMode = all || minimal || (!isTty && !yes);
60
75
 
61
76
  if (legacyMode) {
@@ -77,18 +92,51 @@ try {
77
92
  : !minimal
78
93
  ? ` Default: wrote all 4 supported rule files (safer than detecting one editor and\n missing the actual one). Pass --minimal to write only matched editors next time.\n\n`
79
94
  : "";
95
+
96
+ // v0.2.25 — show both the files we wrote AND the files we skipped (already current).
97
+ // Before, "wrote 2 of 4" was opaque: the user couldn't tell whether the other 2 were
98
+ // intentionally skipped or silently failed. `res.considered` is the full target list
99
+ // for this invocation, so the skipped set is just considered \ written.
100
+ const writtenSet = new Set(res.written);
101
+ const skipped = res.considered.filter((f) => !writtenSet.has(f));
102
+ const skippedBlock = skipped.length > 0
103
+ ? "\n Already current (no change):\n" + skipped.map((f) => ` • ${f}`).join("\n") + "\n"
104
+ : "";
105
+
106
+ // v0.2.25 — Gap 9. Rules-only init writes "always use krimto_*" instructions, but if
107
+ // no editor has Krimto wired into its MCP config, those instructions reference tools
108
+ // that won't exist in chat. Detect and warn so the user doesn't think the chat side
109
+ // is mysteriously broken later.
110
+ let mcpWarning = "";
111
+ try {
112
+ const { detectExistingSetup } = await tsImport("../src/cli/init.ts", import.meta.url);
113
+ const snap = await detectExistingSetup(process.cwd());
114
+ if (snap.registeredEditors.length === 0) {
115
+ mcpWarning =
116
+ "\n⚠️ Rule files written, but NO editor is wired to Krimto yet.\n" +
117
+ " The rules tell your AI to use krimto_*, but those tools won't be available\n" +
118
+ " until you register the MCP server. From a terminal:\n" +
119
+ " $ npx @krimto-labs/krimto init # full interactive wizard\n" +
120
+ " $ npx @krimto-labs/krimto connect # print copy-paste snippets\n";
121
+ }
122
+ } catch {
123
+ /* best-effort — don't block the success path if detection fails */
124
+ }
125
+
80
126
  process.stderr.write(
81
127
  "\n✅ AUTO MODE on — rule written to " + res.written.length + " file" +
82
128
  (res.written.length === 1 ? "" : "s") + "\n" +
83
129
  "\n" +
84
130
  res.written.map((f) => ` ${f}`).join("\n") + "\n" +
131
+ skippedBlock +
85
132
  "\n" +
86
133
  detectedLine +
87
134
  "━━ Next steps ━━\n" +
88
135
  "\n" +
89
- " 1. Restart your editor (so it loads the new rule)\n" +
136
+ " 1. Restart your editor (so it loads the new rule + MCP tools)\n" +
90
137
  " 2. Test in chat: \"Remember that we use pnpm in this repo\"\n" +
91
138
  " 3. Verify it landed: $ npx @krimto-labs/krimto verify-connection\n" +
139
+ mcpWarning +
92
140
  "\n" +
93
141
  "To undo: $ npx @krimto-labs/krimto uninit\n" +
94
142
  "Manual: delete the block between <!-- krimto:start --> and <!-- krimto:end -->\n\n",
@@ -99,9 +147,23 @@ try {
99
147
  const { runInitNonInteractive } = await tsImport("../src/cli/wizard.ts", import.meta.url);
100
148
  const result = await runInitNonInteractive(process.cwd());
101
149
  const wired = result.editorOutcomes.map((o) => o.editor).join(", ") || "(none)";
150
+ // v0.2.27 — surface the port-readiness probe result. If the wizard installed an
151
+ // always-running service AND the port came up, the editor can reconnect immediately.
152
+ // If portReady is false, the wizard prints a warning so the CI/agent caller knows
153
+ // the service is up but its HTTP listener didn't bind in time.
154
+ let serviceLine = ` Run mode: as-needed\n`;
155
+ if (result.serviceInstall) {
156
+ if (result.serviceInstall.portReady === false) {
157
+ serviceLine =
158
+ ` Run mode: always-running ⚠ port did NOT come up within 10s\n` +
159
+ ` Check /tmp/com.krimto.server.err.log for boot errors.\n`;
160
+ } else {
161
+ serviceLine = ` Run mode: always-running · port ready\n`;
162
+ }
163
+ }
102
164
  process.stderr.write(
103
165
  `\n✅ Krimto set up (non-interactive). Editors: ${wired}\n` +
104
- ` Run mode: ${result.serviceInstall ? "always-running" : "as-needed"}\n` +
166
+ serviceLine +
105
167
  ` Search: ${result.embeddingsConfigured ? "OpenAI" : "keyword"}\n` +
106
168
  ` Data: ${result.dataDir}\n\n` +
107
169
  "Restart your editor so it loads the new rule.\n\n",
@@ -362,7 +424,7 @@ try {
362
424
  // `krimto connect` — print stdio connect snippets (the npx on-ramp shape), so a solo user
363
425
  // doesn't have to chase the README. Honors KRIMTO_IDENTITY when set.
364
426
  const { formatConnect } = await tsImport("../src/cli/connect.ts", import.meta.url);
365
- process.stdout.write(formatConnect({ identity: process.env.KRIMTO_IDENTITY }));
427
+ process.stdout.write(await formatConnect({ identity: process.env.KRIMTO_IDENTITY }));
366
428
  } else if (cmd === "whoami") {
367
429
  // `krimto whoami` — show the active KRIMTO_IDENTITY and every place it's currently set
368
430
  // (each editor's MCP config + the always-running service unit). Surfaces drift between
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krimto-labs/krimto",
3
- "version": "0.2.22",
3
+ "version": "0.2.27",
4
4
  "description": "Open-source team memory layer for AI agents — markdown files in git, user/team/org hierarchy, cross-vendor MCP server.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -54,6 +54,7 @@ export async function getLockHolder(dataDir: string): Promise<LockInfo | null> {
54
54
  pid: parsed.pid,
55
55
  started: typeof parsed.started === "string" ? parsed.started : "unknown",
56
56
  mode: (parsed.mode as LockInfo["mode"]) ?? "stdio",
57
+ launchedBy: parsed.launchedBy === "service" ? "service" : "ad-hoc",
57
58
  };
58
59
  }
59
60
  } catch {
@@ -5,13 +5,24 @@
5
5
  import { stdioConnectSnippets } from "../server/connect";
6
6
 
7
7
  export interface ConnectOpts {
8
- /** Identity to embed in the Cursor env block. Defaults to a generic placeholder. */
8
+ /** Identity to embed in the Cursor env block. Defaults to git config user.email, falling back to a generic placeholder. */
9
9
  identity?: string;
10
10
  }
11
11
 
12
- /** Build the multi-line, copy-pasteable output `krimto connect` prints to stdout. */
13
- export function formatConnect(opts: ConnectOpts = {}): string {
14
- const { claude, cursorJson } = stdioConnectSnippets(opts);
12
+ /**
13
+ * Build the multi-line, copy-pasteable output `krimto connect` prints to stdout.
14
+ *
15
+ * v0.2.24 — identity falls back to {@link defaultIdentity} (git config user.email) when not
16
+ * supplied. Before this, the printed snippet always showed `you@acme.com`, so users
17
+ * pasting the snippet ended up with the placeholder as their literal KRIMTO_IDENTITY.
18
+ *
19
+ * v0.2.24 — the printed `claude mcp add` line is now preceded by `claude mcp remove krimto`
20
+ * so a copy-paste rerun doesn't fail with "MCP server krimto already exists in local config"
21
+ * (the same idempotency fix v0.2.19 made to the wizard's MCP writer).
22
+ */
23
+ export async function formatConnect(opts: ConnectOpts = {}): Promise<string> {
24
+ const identity = opts.identity ?? (await defaultIdentityForConnect());
25
+ const { claude, cursorJson } = stdioConnectSnippets({ identity });
15
26
  const indentedJson = cursorJson.split("\n").map((line) => ` ${line}`).join("\n");
16
27
  return [
17
28
  "",
@@ -19,7 +30,8 @@ export function formatConnect(opts: ConnectOpts = {}): string {
19
30
  "",
20
31
  "━━ 1. Paste the config ━━",
21
32
  "",
22
- " Claude Code:",
33
+ " Claude Code (the `remove` clears any prior entry so re-runs don't error):",
34
+ " $ claude mcp remove krimto 2>/dev/null; true",
23
35
  ` $ ${claude}`,
24
36
  "",
25
37
  " Cursor (add to ~/.cursor/mcp.json, then restart Cursor):",
@@ -61,3 +73,22 @@ export function formatConnect(opts: ConnectOpts = {}): string {
61
73
  "",
62
74
  ].join("\n");
63
75
  }
76
+
77
+ /**
78
+ * Local copy of init.ts's git-config lookup, scoped to the connect command. We don't import
79
+ * `defaultIdentity` directly from init.ts to keep `connect` cheap (init.ts pulls in
80
+ * `applyRule`, service installer, MCP writer, etc.). Same regex; same fallback.
81
+ */
82
+ async function defaultIdentityForConnect(): Promise<string> {
83
+ try {
84
+ const { execFile } = await import("node:child_process");
85
+ const { promisify } = await import("node:util");
86
+ const exec = promisify(execFile);
87
+ const { stdout } = await exec("git", ["config", "--global", "user.email"]);
88
+ const email = stdout.trim();
89
+ if (email && /^[^@\s]+@[^@\s]+$/.test(email)) return email;
90
+ } catch {
91
+ /* git missing or unconfigured — fall through to the legacy placeholder */
92
+ }
93
+ return "you@acme.com";
94
+ }
@@ -35,6 +35,7 @@ async function checkLock(dataDir: string): Promise<LockInfo | null> {
35
35
  pid: parsed.pid,
36
36
  started: typeof parsed.started === "string" ? parsed.started : "unknown",
37
37
  mode: (parsed.mode as LockInfo["mode"]) ?? "stdio",
38
+ launchedBy: parsed.launchedBy === "service" ? "service" : "ad-hoc",
38
39
  };
39
40
  }
40
41
  } catch {
package/src/cli/init.ts CHANGED
@@ -435,26 +435,40 @@ export async function detectExistingSetup(
435
435
  let searchProvider: SearchProvider = "keyword";
436
436
 
437
437
  for (const env of envs) {
438
- if (env.mcpWire?.method !== "json") continue;
439
- let text: string;
440
- try {
441
- text = await fs.readFile(env.mcpWire.path, "utf8");
442
- } catch {
438
+ if (env.mcpWire === null) continue;
439
+
440
+ if (env.mcpWire.method === "json") {
441
+ let text: string;
442
+ try {
443
+ text = await fs.readFile(env.mcpWire.path, "utf8");
444
+ } catch {
445
+ continue;
446
+ }
447
+ let parsed: Record<string, unknown>;
448
+ try {
449
+ parsed = JSON.parse(text) as Record<string, unknown>;
450
+ } catch {
451
+ continue;
452
+ }
453
+ const servers = parsed[env.mcpWire.key] as Record<string, unknown> | undefined;
454
+ if (!servers || !("krimto" in servers)) continue;
455
+ registeredEditors.push(env.editor);
456
+ const krimto = servers.krimto as { env?: Record<string, string> };
457
+ if (krimto.env?.KRIMTO_EMBED_PROVIDER === "openai") {
458
+ searchProvider = "openai";
459
+ }
443
460
  continue;
444
461
  }
445
- let parsed: Record<string, unknown>;
446
- try {
447
- parsed = JSON.parse(text) as Record<string, unknown>;
448
- } catch {
462
+
463
+ // v0.2.26 — Gap-3 root-cause fix. Claude Code uses `claude mcp` CLI; the registration
464
+ // doesn't live in a JSON file we scan. Without this branch every read-side surface
465
+ // (reconfigure menu, status, reset, whoami) was invisibly mis-reporting "Cursor only"
466
+ // even after the wizard had successfully wired both Cursor AND Claude Code.
467
+ if (env.mcpWire.method === "cli") {
468
+ const present = await isClaudeMcpRegistered(env.mcpWire.command);
469
+ if (present) registeredEditors.push(env.editor);
449
470
  continue;
450
471
  }
451
- const servers = parsed[env.mcpWire.key] as Record<string, unknown> | undefined;
452
- if (!servers || !("krimto" in servers)) continue;
453
- registeredEditors.push(env.editor);
454
- const krimto = servers.krimto as { env?: Record<string, string> };
455
- if (krimto.env?.KRIMTO_EMBED_PROVIDER === "openai") {
456
- searchProvider = "openai";
457
- }
458
472
  }
459
473
 
460
474
  const service = await isServiceInstalled(undefined, homeDir);
@@ -468,6 +482,21 @@ export async function detectExistingSetup(
468
482
  };
469
483
  }
470
484
 
485
+ /**
486
+ * Check whether `claude mcp list` knows about krimto. Best-effort: any error (CLI missing,
487
+ * timeout, parse failure) returns false rather than blocking the caller. The output format
488
+ * is one server per line, "<name>:<space><url-or-spec>" — we just look for "krimto:" with a
489
+ * non-failing line, which covers both stdio (`krimto: npx -y ...`) and HTTP (`krimto: http://...`).
490
+ */
491
+ async function isClaudeMcpRegistered(claudeBinary: string): Promise<boolean> {
492
+ try {
493
+ const { stdout } = await exec(claudeBinary, ["mcp", "list"], { timeout: 8000 });
494
+ return /^krimto:\s/m.test(stdout);
495
+ } catch {
496
+ return false;
497
+ }
498
+ }
499
+
471
500
  // === Legacy runInit (v0.2.16, kept for --all / --minimal back-compat) ======
472
501
 
473
502
  export interface RunInitOptions {
@@ -0,0 +1,146 @@
1
+ // v0.2.26 — single reconciled view of "what is Krimto doing right now". Replaces the prior
2
+ // ad-hoc detection scattered across status.ts, verifyConnection.ts, whoami.ts, and the
3
+ // wizard's reconfigure menu, which disagreed in the smoke-6 transcript audit:
4
+ // • Cursor mcp.json had krimto, Claude Code's `claude mcp` had krimto, launchctl showed
5
+ // the service loaded — but reconfigure menu said "Editors: Cursor", verify-connection
6
+ // said "Launched by: ad-hoc", and reset said "No changes made".
7
+ //
8
+ // `inspectRuntime` reads every source we care about and reconciles them. Downstream
9
+ // commands consume one consistent view instead of running their own subset of checks.
10
+
11
+ import { promises as fs } from "node:fs";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+
15
+ import { isProcessAlive, type LaunchedBy, type LockInfo, type LockMode } from "../server/lock";
16
+ import {
17
+ detectEditorEnvironments,
18
+ detectExistingSetup,
19
+ type EditorKind,
20
+ type RunMode,
21
+ type SearchProvider,
22
+ type SetupSnapshot,
23
+ } from "./init";
24
+ import { probeServiceState } from "./service";
25
+
26
+ export interface RuntimeLock {
27
+ pid: number;
28
+ started: string;
29
+ mode: LockMode;
30
+ /**
31
+ * The lock file's own `launchedBy` value. Pre-v0.2.25 processes didn't write this field,
32
+ * so we default to "ad-hoc". The reconciled answer (correct even for pre-v0.2.25 runs)
33
+ * is in {@link RuntimeState.effectiveLaunchedBy}.
34
+ */
35
+ launchedBy: LaunchedBy;
36
+ alive: boolean;
37
+ }
38
+
39
+ export interface RuntimeService {
40
+ installed: boolean;
41
+ loaded: boolean;
42
+ runningPid: number | null;
43
+ unitPath: string | undefined;
44
+ }
45
+
46
+ export interface RuntimeState {
47
+ /** Lock file state (parsed + alive-check), or null when no readable lock. */
48
+ lock: RuntimeLock | null;
49
+ /** Cross-platform service probe (unit-on-disk + actually-loaded + running PID). */
50
+ service: RuntimeService;
51
+ /**
52
+ * Reconciled launch source. Trusts launchctl/systemctl over the lock file: if the
53
+ * running PID matches the service's `runningPid`, the process IS service-launched,
54
+ * even when the lock file lacks the `launchedBy` field (pre-v0.2.25 runs).
55
+ */
56
+ effectiveLaunchedBy: LaunchedBy | null;
57
+ /** Snapshot from detectExistingSetup (registered editors, run mode, search provider). */
58
+ snapshot: SetupSnapshot;
59
+ /** Detected editor environments (all 4, with present/installed flags). */
60
+ editors: Awaited<ReturnType<typeof detectEditorEnvironments>>;
61
+ /** Editors registered with Krimto right now. Same as snapshot.registeredEditors. */
62
+ registeredEditors: EditorKind[];
63
+ /** Convenience: the configured run mode (always-running iff a service unit exists). */
64
+ runMode: RunMode;
65
+ /** Convenience: the configured search provider. */
66
+ searchProvider: SearchProvider;
67
+ }
68
+
69
+ export interface InspectOptions {
70
+ cwd?: string;
71
+ homeDir?: string;
72
+ }
73
+
74
+ /**
75
+ * Build a single, reconciled {@link RuntimeState}. Cheap enough to call from every read-side
76
+ * command (one fs read for lock, one launchctl/systemctl print, one `claude mcp list`, one
77
+ * pass over editor MCP configs).
78
+ */
79
+ export async function inspectRuntime(dataDir: string, opts: InspectOptions = {}): Promise<RuntimeState> {
80
+ const cwd = opts.cwd ?? process.cwd();
81
+ const homeDir = opts.homeDir ?? os.homedir();
82
+
83
+ const lock = await readLock(dataDir);
84
+ const snapshot = await detectExistingSetup(cwd, homeDir);
85
+ const editors = await detectEditorEnvironments(cwd, homeDir);
86
+ const service = await probeServiceState(undefined, homeDir);
87
+
88
+ // The reconciliation step. Two signals:
89
+ // 1. The lock file's self-reported launchedBy (pre-v0.2.25 runs default to "ad-hoc").
90
+ // 2. Whether the running PID matches launchd's program-PID.
91
+ // If (2) says yes, we know the process was service-launched regardless of what (1) claims.
92
+ let effectiveLaunchedBy: LaunchedBy | null = null;
93
+ if (lock && lock.alive) {
94
+ if (service.loaded && service.runningPid === lock.pid) {
95
+ effectiveLaunchedBy = "service";
96
+ } else {
97
+ effectiveLaunchedBy = lock.launchedBy;
98
+ }
99
+ }
100
+
101
+ return {
102
+ lock,
103
+ service: {
104
+ installed: service.unitPresent,
105
+ loaded: service.loaded,
106
+ runningPid: service.runningPid,
107
+ unitPath: undefined, // service.ts's isServiceInstalled returns this; we don't surface it in v0.2.26
108
+ },
109
+ effectiveLaunchedBy,
110
+ snapshot,
111
+ editors,
112
+ registeredEditors: snapshot.registeredEditors,
113
+ runMode: snapshot.runMode,
114
+ searchProvider: snapshot.searchProvider,
115
+ };
116
+ }
117
+
118
+ async function readLock(dataDir: string): Promise<RuntimeLock | null> {
119
+ const file = path.join(dataDir, ".krimto", "lock.json");
120
+ let raw: string;
121
+ try {
122
+ raw = await fs.readFile(file, "utf8");
123
+ } catch {
124
+ return null;
125
+ }
126
+ let parsed: Partial<LockInfo>;
127
+ try {
128
+ parsed = JSON.parse(raw) as Partial<LockInfo>;
129
+ } catch {
130
+ return null;
131
+ }
132
+ if (
133
+ typeof parsed.pid !== "number" ||
134
+ typeof parsed.started !== "string" ||
135
+ (parsed.mode !== "stdio" && parsed.mode !== "http")
136
+ ) {
137
+ return null;
138
+ }
139
+ return {
140
+ pid: parsed.pid,
141
+ started: parsed.started,
142
+ mode: parsed.mode,
143
+ launchedBy: parsed.launchedBy === "service" ? "service" : "ad-hoc",
144
+ alive: isProcessAlive(parsed.pid),
145
+ };
146
+ }
@@ -32,6 +32,7 @@ async function checkLock(dataDir: string): Promise<LockInfo | null> {
32
32
  pid: parsed.pid,
33
33
  started: typeof parsed.started === "string" ? parsed.started : "unknown",
34
34
  mode: (parsed.mode as LockInfo["mode"]) ?? "stdio",
35
+ launchedBy: parsed.launchedBy === "service" ? "service" : "ad-hoc",
35
36
  };
36
37
  }
37
38
  } catch {
package/src/cli/reset.ts CHANGED
@@ -20,17 +20,12 @@ import * as path from "node:path";
20
20
  import { removeRule } from "../agentRule";
21
21
  import {
22
22
  detectEditorEnvironments,
23
- detectExistingSetup,
24
23
  type EditorEnvironment,
25
24
  type EditorKind,
26
25
  } from "./init";
27
26
  import { removeMcpConfig } from "./mcpConfig";
28
27
  import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
29
- import {
30
- detectPlatform,
31
- isServiceInstalled,
32
- uninstallService,
33
- } from "./service";
28
+ import { detectPlatform, uninstallService } from "./service";
34
29
 
35
30
  const EDITOR_LABEL: Record<EditorKind, string> = {
36
31
  cursor: "Cursor",
@@ -65,33 +60,52 @@ export async function applyReset(opts: ResetOptions = {}): Promise<ResetResult>
65
60
  const homeDir = opts.homeDir;
66
61
  const dataDir = opts.dataDir ?? path.join(homeDir ?? "", ".krimto");
67
62
 
68
- // 1. Disconnect every detected editor.
63
+ // v0.2.26 reset is now "always sweep, never trust detection". The smoke-6 transcript
64
+ // caught reset reporting "No changes made" while a service was actually loaded and
65
+ // Cursor's mcp.json still had a krimto entry: detection was wrong (Claude Code was
66
+ // invisible to CLI-method scans), so reset's gated-by-detection cleanup ran nothing.
67
+ // Now every cleanup path runs best-effort regardless of what detection thinks. We can
68
+ // distinguish "ran but found nothing" from "intentionally skipped" by inspecting the
69
+ // result type — `editorsDisconnected` only includes editors whose removeMcpConfig
70
+ // returned removed=true, so an empty list still means "nothing to remove" cleanly.
71
+
69
72
  const envs = await detectEditorEnvironments(cwd, homeDir);
70
- const snapshot = await detectExistingSetup(cwd, homeDir);
73
+
74
+ // 1. Try to disconnect every editor we know about, regardless of what detection says.
75
+ // removeMcpConfig is already idempotent — it returns removed=false if there was nothing
76
+ // to remove, so blanket-running it is safe.
71
77
  const editorsDisconnected: EditorKind[] = [];
72
78
  for (const env of envs) {
73
- if (!snapshot.registeredEditors.includes(env.editor)) continue;
74
79
  const res = await removeMcpConfig(env);
75
80
  if (res.removed) editorsDisconnected.push(env.editor);
76
81
  }
77
82
 
78
- // 2. Strip the standing rule from each detected rule file in CWD.
83
+ // 2. Strip the standing rule from each detected rule file in CWD (already idempotent).
79
84
  const rulesStripped: string[] = [];
80
85
  for (const env of envs) {
81
86
  const stripped = await stripRule(cwd, env);
82
87
  if (stripped) rulesStripped.push(env.rulesPath);
83
88
  }
84
89
 
85
- // 3. Uninstall the background service if installed.
90
+ // 3. Uninstall the background service ALWAYS (even if isServiceInstalled said no). The
91
+ // inner uninstall is best-effort: the platform CLI errors when nothing's loaded, but
92
+ // we swallow them. This catches the case where a stale plist exists without an active
93
+ // launchctl entry (or vice versa) — both halves get cleaned in one pass.
86
94
  const platform = detectPlatform();
87
- const current = await isServiceInstalled(platform, homeDir);
88
95
  let serviceRemoved = false;
89
- if (current.installed) {
96
+ try {
90
97
  const res = await uninstallService({ dryRun: opts.dryRun, platform, homeDir });
91
98
  serviceRemoved = res.removed;
99
+ } catch {
100
+ /* uninstall path can throw on unsupported platforms or missing CLIs — we're sweeping, not validating */
92
101
  }
93
102
 
94
- // 4. Wipe the keys store (best-effortfile may not exist).
103
+ // 4. Kill any live ad-hoc Krimto process holding the lock otherwise a `krimto serve`
104
+ // that's still running keeps the data dir busy and the next init will conflict on the
105
+ // lock. Best-effort: SIGTERM, give it 500ms, SIGKILL if still alive.
106
+ await terminateLockHolder(dataDir);
107
+
108
+ // 5. Wipe the keys store (best-effort — file may not exist).
95
109
  const keysPath = path.join(dataDir, ".krimto", "keys.json");
96
110
  let keysWiped = false;
97
111
  try {
@@ -101,6 +115,15 @@ export async function applyReset(opts: ResetOptions = {}): Promise<ResetResult>
101
115
  /* no keys file — fine */
102
116
  }
103
117
 
118
+ // 6. Wipe the lock file itself so the next init starts from a known-clean slate. Without
119
+ // this, a stale lock from a process we just killed can confuse subsequent commands.
120
+ const lockPath = path.join(dataDir, ".krimto", "lock.json");
121
+ try {
122
+ await fs.unlink(lockPath);
123
+ } catch {
124
+ /* no lock — fine */
125
+ }
126
+
104
127
  // 5. Optionally move the data dir to a timestamped trash sibling.
105
128
  let notesTrashedTo: string | undefined;
106
129
  if (opts.wipeNotes) {
@@ -177,6 +200,37 @@ export async function runReset(opts: ResetOptions = {}): Promise<ResetResult | n
177
200
  }
178
201
  }
179
202
 
203
+ /**
204
+ * If the lock file points at a live PID, send SIGTERM then SIGKILL (after 500ms). Best-effort;
205
+ * we don't return the outcome because reset's contract is "do the cleanup, don't validate".
206
+ * v0.2.26 — without this step, a leftover `krimto serve` PID survived `reset` and the next
207
+ * init would either reuse the dead lock OR conflict on the live port (whichever came first).
208
+ */
209
+ async function terminateLockHolder(dataDir: string): Promise<void> {
210
+ const lockPath = path.join(dataDir, ".krimto", "lock.json");
211
+ let pid: number | null = null;
212
+ try {
213
+ const raw = await fs.readFile(lockPath, "utf8");
214
+ const parsed = JSON.parse(raw) as { pid?: unknown };
215
+ if (typeof parsed.pid === "number" && parsed.pid > 0) pid = parsed.pid;
216
+ } catch {
217
+ return; // no lock — nothing to terminate
218
+ }
219
+ if (pid === null) return;
220
+ try {
221
+ process.kill(pid, "SIGTERM");
222
+ } catch {
223
+ return; // already gone
224
+ }
225
+ await new Promise((resolve) => setTimeout(resolve, 500));
226
+ try {
227
+ process.kill(pid, 0); // existence probe
228
+ process.kill(pid, "SIGKILL");
229
+ } catch {
230
+ /* exited cleanly on SIGTERM */
231
+ }
232
+ }
233
+
180
234
  async function stripRule(cwd: string, env: EditorEnvironment): Promise<boolean> {
181
235
  const rulePath = path.join(cwd, env.rulesPath);
182
236
  let existing: string;
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { execFile } from "node:child_process";
23
23
  import { promises as fs } from "node:fs";
24
+ import * as net from "node:net";
24
25
  import * as os from "node:os";
25
26
  import * as path from "node:path";
26
27
  import { promisify } from "node:util";
@@ -55,6 +56,16 @@ export interface InstallResult {
55
56
  activateCommand?: { command: string; args: string[] };
56
57
  /** True when the platform CLI was actually executed (false in dry-run mode). */
57
58
  activated: boolean;
59
+ /**
60
+ * v0.2.27 — only meaningful when `activated: true` and the service config sets
61
+ * `KRIMTO_HTTP_PORT`. `true` = TCP connection to `localhost:<port>` was accepted before
62
+ * we returned, so the server is actually serving and clients (Cursor / Claude Code) won't
63
+ * hit ECONNREFUSED if they reconnect immediately. `false` = the probe timed out; the
64
+ * service IS installed and launchd reports it running, but the server hasn't bound the
65
+ * port — usually a misconfig surfaced in the stderr log file. `undefined` = no probe ran
66
+ * (dry run, stdio mode, or no HTTP port in the env block).
67
+ */
68
+ portReady?: boolean;
58
69
  }
59
70
 
60
71
  export interface UninstallResult {
@@ -74,6 +85,14 @@ export interface ServiceOptions {
74
85
  * CI runner without `process.platform` rewiring.
75
86
  */
76
87
  platform?: ServicePlatform;
88
+ /**
89
+ * v0.2.27 — dependency-injected port probe. Default uses real `net.connect`. Tests pass
90
+ * a stub that resolves immediately (so they don't open real TCP sockets). When omitted,
91
+ * `installService` polls `localhost:<KRIMTO_HTTP_PORT>` until accept or `probeTimeoutMs`.
92
+ */
93
+ probePort?: (port: number) => Promise<boolean>;
94
+ /** Maximum time (ms) to wait for the HTTP port to start accepting connections. Default 10000. */
95
+ probeTimeoutMs?: number;
77
96
  }
78
97
 
79
98
  /** Map `process.platform` → the four service flavors Krimto knows about. */
@@ -120,6 +139,70 @@ export async function isServiceInstalled(
120
139
  }
121
140
  }
122
141
 
142
+ /**
143
+ * v0.2.26 — richer probe than {@link isServiceInstalled}. Distinguishes three states the
144
+ * smoke-6 transcript proved we needed:
145
+ * • `unitPresent: true, loaded: false` — plist on disk but not bootstrapped (failed install left over)
146
+ * • `unitPresent: true, loaded: true` — fully active service
147
+ * • `unitPresent: false, loaded: false` — clean machine (or service was uninstalled)
148
+ *
149
+ * Also returns the PID launchd is currently running, so the caller can correlate it against
150
+ * the lock file — when the running Krimto PID equals launchd's `pid`, the process IS the
151
+ * service-launched one, even if its lock file is missing the `launchedBy` field (because the
152
+ * process started under an old version).
153
+ */
154
+ export async function probeServiceState(
155
+ platform: ServicePlatform = detectPlatform(),
156
+ homeDir: string = os.homedir(),
157
+ ): Promise<{
158
+ platform: ServicePlatform;
159
+ unitPresent: boolean;
160
+ loaded: boolean;
161
+ runningPid: number | null;
162
+ }> {
163
+ const base = await isServiceInstalled(platform, homeDir);
164
+ const unitPresent = base.installed;
165
+
166
+ if (platform === "darwin") {
167
+ const uid = process.getuid?.() ?? 501;
168
+ try {
169
+ const { stdout } = await exec("launchctl", ["print", `gui/${uid}/${SERVICE_LABEL}`]);
170
+ const pidMatch = stdout.match(/\bpid\s*=\s*(\d+)/);
171
+ const pid = pidMatch && pidMatch[1] ? Number.parseInt(pidMatch[1], 10) : null;
172
+ return { platform, unitPresent, loaded: true, runningPid: pid };
173
+ } catch {
174
+ return { platform, unitPresent, loaded: false, runningPid: null };
175
+ }
176
+ }
177
+ if (platform === "linux") {
178
+ try {
179
+ // `systemctl --user is-active <name>` exits 0 with "active" when running.
180
+ const { stdout } = await exec("systemctl", ["--user", "is-active", SERVICE_NAME]);
181
+ const active = stdout.trim() === "active";
182
+ if (!active) return { platform, unitPresent, loaded: false, runningPid: null };
183
+ // Best-effort PID lookup.
184
+ try {
185
+ const { stdout: pidOut } = await exec("systemctl", ["--user", "show", "--property=MainPID", "--value", SERVICE_NAME]);
186
+ const pid = Number.parseInt(pidOut.trim(), 10);
187
+ return { platform, unitPresent, loaded: true, runningPid: Number.isFinite(pid) && pid > 0 ? pid : null };
188
+ } catch {
189
+ return { platform, unitPresent, loaded: true, runningPid: null };
190
+ }
191
+ } catch {
192
+ return { platform, unitPresent, loaded: false, runningPid: null };
193
+ }
194
+ }
195
+ if (platform === "win32") {
196
+ try {
197
+ await exec("schtasks", ["/Query", "/TN", SERVICE_NAME]);
198
+ return { platform, unitPresent: true, loaded: true, runningPid: null };
199
+ } catch {
200
+ return { platform, unitPresent: false, loaded: false, runningPid: null };
201
+ }
202
+ }
203
+ return { platform, unitPresent: false, loaded: false, runningPid: null };
204
+ }
205
+
123
206
  /**
124
207
  * Install the service for the current platform. Writes the unit file (where applicable) and
125
208
  * activates the service via the platform's CLI. Pass `opts.dryRun=true` to skip activation
@@ -132,14 +215,82 @@ export async function installService(
132
215
  const platform = opts.platform ?? detectPlatform();
133
216
  const homeDir = config.homeDir ?? os.homedir();
134
217
 
135
- if (platform === "darwin") return installLaunchd(config, homeDir, opts);
136
- if (platform === "linux") return installSystemd(config, homeDir, opts);
137
- if (platform === "win32") return installSchtasks(config, opts);
218
+ // v0.2.25 Gap 8 provenance. Every service-launched Krimto stamps `launchedBy: "service"`
219
+ // into its lock file. We inject the marker env var here (vs every caller remembering to)
220
+ // so the wizard, the `service` shortcut, and any future installer share the same shape.
221
+ const configWithMarker: ServiceConfig = {
222
+ ...config,
223
+ env: { KRIMTO_LAUNCHED_BY: "service", ...(config.env ?? {}) },
224
+ };
225
+
226
+ let result: InstallResult;
227
+ if (platform === "darwin") {
228
+ result = await installLaunchd(configWithMarker, homeDir, opts);
229
+ } else if (platform === "linux") {
230
+ result = await installSystemd(configWithMarker, homeDir, opts);
231
+ } else if (platform === "win32") {
232
+ result = await installSchtasks(configWithMarker, opts);
233
+ } else {
234
+ throw new Error(
235
+ `Krimto's "Always running" mode isn't supported on platform "${process.platform}" yet. ` +
236
+ `Use "As needed" mode (the wizard default) or run \`krimto serve\` manually.`,
237
+ );
238
+ }
138
239
 
139
- throw new Error(
140
- `Krimto's "Always running" mode isn't supported on platform "${process.platform}" yet. ` +
141
- `Use "As needed" mode (the wizard default) or run \`krimto serve\` manually.`,
142
- );
240
+ // v0.2.27 — port-readiness probe. The smoke-6 transcript showed Cursor failing with
241
+ // ECONNREFUSED in the ~3-second window between launchd accepting the bootstrap and the
242
+ // Node process actually binding the HTTP port. The wizard's "Background service installed
243
+ // and started" line ran during that window, so users restarted their editor right when
244
+ // the port was still unbound. Now we wait for the port to accept a TCP connection before
245
+ // returning success; if it doesn't come up within `probeTimeoutMs`, the wizard surfaces
246
+ // the warning instead of giving false confidence.
247
+ if (result.activated && !opts.dryRun) {
248
+ const portStr = configWithMarker.env?.KRIMTO_HTTP_PORT;
249
+ const port = portStr ? Number.parseInt(portStr, 10) : NaN;
250
+ if (Number.isFinite(port) && port > 0) {
251
+ const probe = opts.probePort ?? defaultPortProbe;
252
+ result.portReady = await waitForPort(port, probe, opts.probeTimeoutMs ?? 10000);
253
+ }
254
+ }
255
+ return result;
256
+ }
257
+
258
+ /**
259
+ * Poll the port via the injected probe every 250ms until it accepts or the timeout elapses.
260
+ * Returns true on first successful connect, false on timeout. v0.2.27.
261
+ */
262
+ async function waitForPort(
263
+ port: number,
264
+ probe: (port: number) => Promise<boolean>,
265
+ timeoutMs: number,
266
+ ): Promise<boolean> {
267
+ const start = Date.now();
268
+ while (Date.now() - start < timeoutMs) {
269
+ if (await probe(port)) return true;
270
+ await new Promise((r) => setTimeout(r, 250));
271
+ }
272
+ return false;
273
+ }
274
+
275
+ /**
276
+ * Real port probe: open a TCP socket to 127.0.0.1:<port>, resolve true on `connect`, false on
277
+ * error or 500ms timeout. The probe itself is cheap (kernel-level connect refusal), so a tight
278
+ * 250ms poll loop over a 10s window is fine.
279
+ */
280
+ async function defaultPortProbe(port: number): Promise<boolean> {
281
+ return new Promise<boolean>((resolve) => {
282
+ const socket = net.connect({ port, host: "127.0.0.1" });
283
+ let done = false;
284
+ const finish = (ok: boolean): void => {
285
+ if (done) return;
286
+ done = true;
287
+ socket.destroy();
288
+ resolve(ok);
289
+ };
290
+ socket.once("connect", () => finish(true));
291
+ socket.once("error", () => finish(false));
292
+ socket.setTimeout(500, () => finish(false));
293
+ });
143
294
  }
144
295
 
145
296
  /** Uninstall the service. Removes the unit file + invokes the platform CLI to deactivate it. */
@@ -212,6 +363,31 @@ async function installLaunchd(
212
363
  if (opts.dryRun) {
213
364
  return { platform: "darwin", unitPath, unitContents, activateCommand, activated: false };
214
365
  }
366
+ // v0.2.26 — supersedes the v0.2.23 bootout+bootstrap pattern. The previous fix raced
367
+ // against launchd's still-tearing-down state: `launchctl bootout` returns when the unload
368
+ // is QUEUED, not when it completes, so the subsequent `bootstrap` could still hit EIO.
369
+ // Correct pattern:
370
+ // • If the service is currently loaded → `launchctl kickstart -k gui/<uid>/<label>`
371
+ // atomically kills + restarts the running process. Because we already overwrote the
372
+ // plist above, the new process starts with the latest env / argv.
373
+ // • If the service is NOT loaded → plain `bootstrap`. No race possible.
374
+ // `launchctl print` returning exit-0 is the canonical "is it loaded" probe.
375
+ const printRes = await exec("launchctl", ["print", `gui/${uid}/${SERVICE_LABEL}`]).then(
376
+ () => ({ loaded: true }),
377
+ () => ({ loaded: false }),
378
+ );
379
+ if (printRes.loaded) {
380
+ // Kickstart -k: send SIGTERM, wait for clean exit, then restart from the plist on disk.
381
+ // launchctl returns from kickstart only AFTER the new process is up, so no follow-on race.
382
+ await exec("launchctl", ["kickstart", "-k", `gui/${uid}/${SERVICE_LABEL}`]);
383
+ return {
384
+ platform: "darwin",
385
+ unitPath,
386
+ unitContents,
387
+ activateCommand: { command: "launchctl", args: ["kickstart", "-k", `gui/${uid}/${SERVICE_LABEL}`] },
388
+ activated: true,
389
+ };
390
+ }
215
391
  await exec(activateCommand.command, activateCommand.args);
216
392
  return { platform: "darwin", unitPath, unitContents, activateCommand, activated: true };
217
393
  }
package/src/cli/status.ts CHANGED
@@ -16,14 +16,14 @@ import * as path from "node:path";
16
16
  import { promisify } from "node:util";
17
17
 
18
18
  import { ActivityLog, type ActivityEntry } from "../server/activity";
19
- import { isProcessAlive, type LockInfo } from "../server/lock";
19
+ import { type LockInfo } from "../server/lock";
20
20
  import { KRIMTO_VERSION } from "../server/index";
21
21
  import {
22
22
  detectEditorEnvironments,
23
- detectExistingSetup,
24
23
  type EditorKind,
25
24
  type SetupSnapshot,
26
25
  } from "./init";
26
+ import { inspectRuntime } from "./inspectRuntime";
27
27
 
28
28
  const exec = promisify(execFile);
29
29
 
@@ -66,9 +66,13 @@ export async function runStatus(
66
66
  const homeDir = opts.homeDir ?? os.homedir();
67
67
  const now = opts.now ?? new Date();
68
68
 
69
- const snapshot = await detectExistingSetup(cwd, homeDir);
70
- const envs = await detectEditorEnvironments(cwd, homeDir);
71
- const lock = await readLock(dataDir);
69
+ // v0.2.26 single source of truth. status used to read 4 sources separately and the
70
+ // results disagreed (smoke-6 transcript). Now everything flows through inspectRuntime,
71
+ // which reconciles lock + launchctl + editor configs into one consistent view.
72
+ const runtime = await inspectRuntime(dataDir, { cwd, homeDir });
73
+ const snapshot = runtime.snapshot;
74
+ const envs = runtime.editors;
75
+ const lock = runtime.lock ? { info: { pid: runtime.lock.pid, started: runtime.lock.started, mode: runtime.lock.mode, launchedBy: runtime.lock.launchedBy }, alive: runtime.lock.alive } : null;
72
76
  const log = new ActivityLog(dataDir);
73
77
  const recent = await log.tail(5);
74
78
  const stats = await log.stats(5 * 60 * 1000, now);
@@ -76,7 +80,7 @@ export async function runStatus(
76
80
  const gitInfo = await readGitInfo(dataDir);
77
81
 
78
82
  const overall = pickOverall(snapshot, lock, stats);
79
- const header = headerLine(overall, lock, now);
83
+ const header = headerLine(overall, lock, runtime.effectiveLaunchedBy, now);
80
84
 
81
85
  const connectionsBlock = renderConnections(envs, snapshot, recent);
82
86
  const storageBlock = renderStorage(dataDir, gitInfo, indexStats);
@@ -99,24 +103,6 @@ export async function runStatus(
99
103
 
100
104
  // === Helpers ===============================================================
101
105
 
102
- async function readLock(dataDir: string): Promise<{ info: LockInfo; alive: boolean } | null> {
103
- try {
104
- const raw = await fs.readFile(path.join(dataDir, ".krimto", "lock.json"), "utf8");
105
- const parsed = JSON.parse(raw) as Partial<LockInfo>;
106
- if (
107
- typeof parsed.pid === "number" &&
108
- typeof parsed.started === "string" &&
109
- (parsed.mode === "stdio" || parsed.mode === "http")
110
- ) {
111
- const info: LockInfo = { pid: parsed.pid, started: parsed.started, mode: parsed.mode };
112
- return { info, alive: isProcessAlive(info.pid) };
113
- }
114
- } catch {
115
- /* no lock file */
116
- }
117
- return null;
118
- }
119
-
120
106
  async function readIndexStats(dataDir: string): Promise<{ exists: boolean; modified?: Date }> {
121
107
  try {
122
108
  const s = await fs.stat(path.join(dataDir, "index.db"));
@@ -171,11 +157,12 @@ function pickOverall(
171
157
  function headerLine(
172
158
  status: "ok" | "warning" | "error",
173
159
  lock: { info: LockInfo; alive: boolean } | null,
160
+ effectiveLaunchedBy: import("./inspectRuntime").RuntimeState["effectiveLaunchedBy"],
174
161
  now: Date,
175
162
  ): string {
176
163
  if (status === "ok") {
177
164
  if (lock?.alive) {
178
- return `\n✅ Krimto is working · v${KRIMTO_VERSION}\n PID ${lock.info.pid} (${lock.info.mode}), started ${humanAgo(lock.info.started, now)}\n`;
165
+ return `\n✅ Krimto is working · v${KRIMTO_VERSION}\n PID ${lock.info.pid} (${lock.info.mode}, ${effectiveLaunchedBy ?? lock.info.launchedBy}), started ${humanAgo(lock.info.started, now)}\n`;
179
166
  }
180
167
  return `\n✅ Krimto is configured · v${KRIMTO_VERSION}\n No active server right now — it will be launched on demand by your editor.\n`;
181
168
  }
package/src/cli/usage.ts CHANGED
@@ -13,6 +13,7 @@ const EXPECTED: readonly string[] = [
13
13
  "krimto_read",
14
14
  "krimto_supersede",
15
15
  "krimto_list_scopes",
16
+ "krimto_whoami",
16
17
  ];
17
18
  if (MCP_TOOL_NAMES.length !== EXPECTED.length || MCP_TOOL_NAMES.some((t, i) => t !== EXPECTED[i])) {
18
19
  throw new Error("src/cli/usage.ts examples are out of sync with src/server/connect.ts MCP_TOOL_NAMES");
@@ -24,13 +25,14 @@ export function formatUsage(version: string): string {
24
25
  "",
25
26
  `✅ How to use Krimto (v${version})`,
26
27
  "",
27
- "━━ The 5 tools ━━",
28
+ "━━ The 6 tools ━━",
28
29
  "",
29
30
  " krimto_write Save a fact",
30
31
  " krimto_recall Search facts",
31
32
  " krimto_read Open one fact by id",
32
33
  " krimto_supersede Replace a fact with a new version (old kept in git)",
33
34
  " krimto_list_scopes See which scopes you can read",
35
+ " krimto_whoami Ask Krimto which identity you're writing as (and your scopes)",
34
36
  "",
35
37
  "━━ DEFAULT MODE — explicit (\"use krimto to ...\") ━━",
36
38
  "",
@@ -8,6 +8,7 @@ import { promises as fs } from "node:fs";
8
8
  import * as path from "node:path";
9
9
 
10
10
  import { ActivityLog, type ActivityEntry } from "../server/activity";
11
+ import { inspectRuntime } from "./inspectRuntime";
11
12
  import { isProcessAlive, type LockInfo } from "../server/lock";
12
13
 
13
14
  export interface VerifyConnectionResult {
@@ -39,7 +40,12 @@ export async function runVerifyConnection(dataDir: string, now: Date = new Date(
39
40
  const raw = await fs.readFile(lockPath, "utf8");
40
41
  const parsed = JSON.parse(raw) as Partial<LockInfo>;
41
42
  if (typeof parsed.pid === "number" && typeof parsed.started === "string" && (parsed.mode === "stdio" || parsed.mode === "http")) {
42
- lock = { pid: parsed.pid, started: parsed.started, mode: parsed.mode };
43
+ lock = {
44
+ pid: parsed.pid,
45
+ started: parsed.started,
46
+ mode: parsed.mode,
47
+ launchedBy: parsed.launchedBy === "service" ? "service" : "ad-hoc",
48
+ };
43
49
  } else {
44
50
  lockMalformed = true;
45
51
  }
@@ -56,12 +62,22 @@ export async function runVerifyConnection(dataDir: string, now: Date = new Date(
56
62
  let header: string;
57
63
  if (lock && isProcessAlive(lock.pid)) {
58
64
  status = "running";
65
+ // v0.2.26 — reconcile the lock's self-reported launchedBy against launchctl's actual
66
+ // state. Without this, a pre-v0.2.25 service-launched process reports "ad-hoc" because
67
+ // its lock file was written before the field existed.
68
+ const runtime = await inspectRuntime(dataDir);
69
+ const effective = runtime.effectiveLaunchedBy ?? lock.launchedBy;
70
+ const launchedByLabel =
71
+ effective === "service"
72
+ ? "service (launchd/systemd/schtasks — survives reboot)"
73
+ : "ad-hoc (started by `krimto serve` or an editor's stdio launcher)";
59
74
  header =
60
75
  `\n🟢 Krimto running\n` +
61
- ` PID: ${lock.pid}\n` +
62
- ` Mode: ${lock.mode}\n` +
63
- ` Started: ${humanAgo(lock.started, now)}\n` +
64
- ` Data: ${dataDir}\n`;
76
+ ` PID: ${lock.pid}\n` +
77
+ ` Mode: ${lock.mode}\n` +
78
+ ` Started: ${humanAgo(lock.started, now)}\n` +
79
+ ` Launched by: ${launchedByLabel}\n` +
80
+ ` Data: ${dataDir}\n`;
65
81
  } else if (lock) {
66
82
  status = "stale";
67
83
  header =
package/src/cli/wizard.ts CHANGED
@@ -357,11 +357,22 @@ function printApplyResult(res: ApplyResult, io: WizardIO): void {
357
357
  }
358
358
  }
359
359
  if (res.serviceInstall) {
360
- io.out(
361
- res.serviceInstall.activated
362
- ? ` ✓ Background service installed and started (${res.serviceInstall.platform})\n`
363
- : ` ✓ Background service configured (${res.serviceInstall.platform}; dry-run)\n`,
364
- );
360
+ if (!res.serviceInstall.activated) {
361
+ io.out(` ✓ Background service configured (${res.serviceInstall.platform}; dry-run)\n`);
362
+ } else if (res.serviceInstall.portReady === false) {
363
+ // v0.2.27 install succeeded but the HTTP port didn't come up within the probe
364
+ // window. Editors that auto-reconnect on MCP-config change will hit ECONNREFUSED.
365
+ // Surface the warning + pointer to the log file instead of giving false confidence.
366
+ io.out(
367
+ ` ⚠ Background service installed (${res.serviceInstall.platform}) but the HTTP port\n` +
368
+ ` didn't come up within 10s. Check /tmp/com.krimto.server.err.log for boot errors.\n` +
369
+ ` Editors may fail to connect until the server binds the port.\n`,
370
+ );
371
+ } else {
372
+ // portReady === true OR undefined (no HTTP port configured — stdio-only install).
373
+ const readinessNote = res.serviceInstall.portReady === true ? " · port accepting connections" : "";
374
+ io.out(` ✓ Background service installed and started (${res.serviceInstall.platform})${readinessNote}\n`);
375
+ }
365
376
  }
366
377
  if (res.embeddingsConfigured) io.out(" ✓ Semantic search enabled (OpenAI)\n");
367
378
  // Tell the user where their data will live. The folder itself is created lazily on first save,
@@ -388,7 +399,10 @@ function printApplyResult(res: ApplyResult, io: WizardIO): void {
388
399
  }
389
400
  }
390
401
 
391
- io.out("Restart your editor once so it picks up the new rule.\n");
402
+ io.out(
403
+ "Restart your editor once so it loads the new MCP server (the krimto_*\n" +
404
+ "tools won't appear in chat until you do) and picks up the standing rule.\n",
405
+ );
392
406
  }
393
407
 
394
408
  function printRefreshSummary(res: ApplyResult, io: WizardIO): void {
@@ -74,13 +74,14 @@ export function cursorDeeplink(host: string): string {
74
74
  return `cursor://anysphere.cursor-deeplink/mcp/install?name=krimto&config=${config}`;
75
75
  }
76
76
 
77
- /** The five MCP tools Krimto exposes (kept in lockstep with src/server/index.ts registrations). */
77
+ /** The six MCP tools Krimto exposes (kept in lockstep with src/server/index.ts registrations). */
78
78
  export const MCP_TOOL_NAMES = [
79
79
  "krimto_write",
80
80
  "krimto_recall",
81
81
  "krimto_read",
82
82
  "krimto_supersede",
83
83
  "krimto_list_scopes",
84
+ "krimto_whoami",
84
85
  ] as const;
85
86
 
86
87
  /** The transport-level contract for wiring up any MCP client we haven't shipped a verified snippet for. */
@@ -41,6 +41,7 @@ import {
41
41
  krimtoRead,
42
42
  krimtoRecall,
43
43
  krimtoSupersede,
44
+ krimtoWhoami,
44
45
  krimtoWrite,
45
46
  type ToolContext,
46
47
  } from "./tools";
@@ -48,7 +49,7 @@ import { type Requester } from "../access/scope";
48
49
 
49
50
  export type RequesterResolver = (extra: { authInfo?: AuthInfo }) => Requester;
50
51
 
51
- export const KRIMTO_VERSION = "0.2.22";
52
+ export const KRIMTO_VERSION = "0.2.27";
52
53
 
53
54
  export function resolveDataDir(): string {
54
55
  return process.env.KRIMTO_DATA ?? path.join(homedir(), ".krimto");
@@ -192,6 +193,24 @@ export function buildServer(ctx: ToolContext, resolveRequester?: RequesterResolv
192
193
  },
193
194
  );
194
195
 
196
+ server.registerTool(
197
+ "krimto_whoami",
198
+ {
199
+ description:
200
+ "Report the caller's identity plus the scopes they can read and write. Call this before " +
201
+ "claiming to know the user's email or which team scopes exist — Krimto knows; the agent doesn't.",
202
+ inputSchema: {},
203
+ },
204
+ async (_args, extra) => {
205
+ try {
206
+ const requester = resolveRequester ? resolveRequester(extra) : ctx.requester;
207
+ return ok(await krimtoWhoami({ ...ctx, requester }));
208
+ } catch (e) {
209
+ return fail(e);
210
+ }
211
+ },
212
+ );
213
+
195
214
  return server;
196
215
  }
197
216
 
@@ -16,11 +16,19 @@ import { promises as fs } from "node:fs";
16
16
  import * as path from "node:path";
17
17
 
18
18
  export type LockMode = "stdio" | "http";
19
+ /**
20
+ * How this Krimto process was launched. "service" = via launchd / systemd / schtasks (the
21
+ * always-running setup); "ad-hoc" = direct invocation (`krimto serve`, editor-spawned stdio,
22
+ * tests). The service installers inject KRIMTO_LAUNCHED_BY=service into the unit env, so this
23
+ * field is just `process.env.KRIMTO_LAUNCHED_BY` at acquire time with a safe default.
24
+ */
25
+ export type LaunchedBy = "service" | "ad-hoc";
19
26
 
20
27
  export interface LockInfo {
21
28
  pid: number;
22
29
  started: string;
23
30
  mode: LockMode;
31
+ launchedBy: LaunchedBy;
24
32
  }
25
33
 
26
34
  export interface LockHandle {
@@ -73,6 +81,7 @@ export async function acquireLock(dataDir: string, mode: LockMode): Promise<Lock
73
81
  pid: existing.pid,
74
82
  started: typeof existing.started === "string" ? existing.started : "unknown",
75
83
  mode: (existing.mode as LockMode) ?? "stdio",
84
+ launchedBy: existing.launchedBy === "service" ? "service" : "ad-hoc",
76
85
  },
77
86
  file,
78
87
  );
@@ -83,7 +92,8 @@ export async function acquireLock(dataDir: string, mode: LockMode): Promise<Lock
83
92
  // File missing / unreadable / malformed — treat as no lock and continue.
84
93
  }
85
94
 
86
- const info: LockInfo = { pid: process.pid, started: new Date().toISOString(), mode };
95
+ const launchedBy: LaunchedBy = process.env.KRIMTO_LAUNCHED_BY === "service" ? "service" : "ad-hoc";
96
+ const info: LockInfo = { pid: process.pid, started: new Date().toISOString(), mode, launchedBy };
87
97
  await fs.writeFile(file, JSON.stringify(info, null, 2), "utf8");
88
98
 
89
99
  return {
@@ -109,6 +109,20 @@ export interface SupersedeResult {
109
109
 
110
110
  export interface ListScopesResult {
111
111
  scopes: { path: string; fact_count: number; last_updated: string | null }[];
112
+ /** Present only when `scopes` is empty — tells the calling agent how to make a scope appear. */
113
+ hint?: string;
114
+ }
115
+
116
+ /**
117
+ * v0.2.25 — Gap 3. The smoke-6 transcript showed an agent in chat inventing a wrong identity
118
+ * (`lpd.themes@gmail.com` instead of `lpdthemes@gmail.com`) because it had no MCP-side way to
119
+ * ask "who am I writing as?". `krimto_whoami` returns the resolved identity plus the scopes
120
+ * the caller can read and write, so the agent never has to guess.
121
+ */
122
+ export interface WhoamiResult {
123
+ identity: string;
124
+ readable_scopes: string[];
125
+ writable_scopes: string[];
112
126
  }
113
127
 
114
128
  function clock(ctx: ToolContext): Date {
@@ -329,17 +343,42 @@ export async function krimtoSupersede(
329
343
  });
330
344
  }
331
345
 
346
+ /**
347
+ * Return the caller's identity plus the scopes they can read and write. The agent in chat uses
348
+ * this to avoid hallucinating identity (Gap 3 from the smoke-6 transcript audit).
349
+ */
350
+ export async function krimtoWhoami(ctx: ToolContext): Promise<WhoamiResult> {
351
+ const readable = readableScopesFor(ctx);
352
+ const writable = writableScopesFor(ctx);
353
+ if (ctx.activity) await ctx.activity.record("krimto_whoami", ctx.requester.identity, ctx.requester.identity);
354
+ return {
355
+ identity: ctx.requester.identity,
356
+ readable_scopes: readable,
357
+ writable_scopes: writable,
358
+ };
359
+ }
360
+
332
361
  /** Discover the scopes that exist and what they contain. */
333
362
  export async function krimtoListScopes(ctx: ToolContext): Promise<ListScopesResult> {
334
363
  const scopes = ctx.index.listScopes(readableScopesFor(ctx));
335
364
  if (ctx.activity) await ctx.activity.record("krimto_list_scopes", ctx.requester.identity, `${scopes.length} scope(s)`);
336
- return {
365
+ const result: ListScopesResult = {
337
366
  scopes: scopes.map((s) => ({
338
367
  path: s.path,
339
368
  fact_count: s.factCount,
340
369
  last_updated: s.lastUpdated,
341
370
  })),
342
371
  };
372
+ // v0.2.24 — empty result is the #1 first-impression confuser ("Krimto must be broken").
373
+ // Surface the next step in the response itself, so an agent in chat can relay it verbatim
374
+ // instead of inventing a hallucinated explanation about "scopes aren't configured".
375
+ if (result.scopes.length === 0) {
376
+ result.hint =
377
+ `No scopes exist yet for ${ctx.requester.identity}. Scopes are created on the first ` +
378
+ `write — try krimto_write with a small fact (e.g. "we use pnpm in this repo") and ` +
379
+ `your user/<email> scope will appear here.`;
380
+ }
381
+ return result;
343
382
  }
344
383
 
345
384
  /** Build a Requester from a validated bearer token's AuthInfo (its `extra` carries identity+teams). */