kojee-mcp 0.7.5-staging.4 → 0.7.5-staging.5

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
@@ -189,8 +189,11 @@ Per-runtime config-path / hooks-path overrides: `--config-path`, `--hooks-path`.
189
189
  injector — no webhook receiver, no `--webhook-url`.
190
190
  - **hermes** — a **daemon** feeding a webhook receiver. `init --runtime hermes`
191
191
  (or `connect --runtime hermes`) validates the webhook env and prints + records
192
- the env to export (secret redacted) into a source-able `~/.kojee/hermes.env`.
193
- `--webhook-url` is optional (defaults to the hermes-plugin loopback receiver).
192
+ the env (secret redacted) into a single-quoted `~/.kojee/hermes.env`, loaded
193
+ with `set -a; . ~/.kojee/hermes.env; set +a` (the installed service unit's
194
+ `ExecStart` uses this exact wrapper so every var is exported to the daemon on
195
+ both Linux and macOS). `--webhook-url` is optional (defaults to the
196
+ hermes-plugin loopback receiver).
194
197
 
195
198
  `kojee-mcp init --uninstall` is runtime-aware (uses the recorded runtime when
196
199
  `--runtime` is omitted). `kojee-mcp doctor` is runtime-aware too.
@@ -10,6 +10,12 @@ import {
10
10
  removeOpenclawMcpServer,
11
11
  writeOpenclawMcpConfig
12
12
  } from "./chunk-E6WMFMM2.js";
13
+ import {
14
+ HERMES_INSTANCE_KEY,
15
+ defaultHermesConfigPath,
16
+ removeHermesMcpServer,
17
+ writeHermesMcpConfig
18
+ } from "./chunk-GEGUWQYT.js";
13
19
  import {
14
20
  WIZARD_RUNTIMES,
15
21
  isWizardRuntime
@@ -135,8 +141,8 @@ function pairSlotFor(runtime, code) {
135
141
 
136
142
  // src/wizard/wizard.ts
137
143
  import crypto3 from "crypto";
138
- import fs7 from "fs";
139
- import path8 from "path";
144
+ import fs6 from "fs";
145
+ import path7 from "path";
140
146
  import { fileURLToPath as fileURLToPath2 } from "url";
141
147
 
142
148
  // src/wizard/skill-install.ts
@@ -354,8 +360,7 @@ function writeWebhookSecretBothSides(opts) {
354
360
  ...opts.daemonExtraEnv ?? []
355
361
  ];
356
362
  upsertEnvFile(opts.daemonEnvPath, daemonVars, {
357
- exportPrefix: true,
358
- header: `# kojee daemon env for runtime=${opts.runtime} (source this before starting the daemon)`
363
+ header: `# kojee daemon env for runtime=${opts.runtime} (load with: set -a; . <this file>; set +a)`
359
364
  });
360
365
  secureFile(opts.daemonEnvPath);
361
366
  const adapterVars = [
@@ -369,7 +374,7 @@ function writeWebhookSecretBothSides(opts) {
369
374
  }
370
375
 
371
376
  // src/wizard/installers/hermes.ts
372
- import path7 from "path";
377
+ import path6 from "path";
373
378
 
374
379
  // src/wizard/plugin-payload.ts
375
380
  import fs4 from "fs";
@@ -419,6 +424,12 @@ function installBundledPayload(opts) {
419
424
  // src/wizard/service.ts
420
425
  import fs5 from "fs";
421
426
  import path5 from "path";
427
+ function daemonSourceExecCommand(spec) {
428
+ return `set -a; . ${spec.envFile}; set +a; exec ${spec.binPath}`;
429
+ }
430
+ function xmlEscape(value) {
431
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
432
+ }
422
433
  function systemdUnit(spec) {
423
434
  return [
424
435
  "[Unit]",
@@ -428,9 +439,14 @@ function systemdUnit(spec) {
428
439
  "",
429
440
  "[Service]",
430
441
  "Type=simple",
431
- `EnvironmentFile=${spec.envFile}`,
432
- `Environment=KOJEE_RUNTIME=${spec.runtime}`,
433
- `ExecStart=${spec.binPath}`,
442
+ // §2b DISCRIMINATOR: KOJEE_WEBHOOK_EXPECTED lives in the WRAPPER env (this
443
+ // Environment= line), NOT in the sourced env file — a same-class drop would
444
+ // otherwise kill the marker too. It lets startProxy tell the "webhook was
445
+ // meant to be here" daemon apart from the tools-only child (which never sets
446
+ // it) when the sourced URL is absent → null delivery.
447
+ `Environment=KOJEE_RUNTIME=${spec.runtime} KOJEE_WEBHOOK_EXPECTED=1`,
448
+ // §2a: source the env file inside a shell wrapper (replaces EnvironmentFile=).
449
+ `ExecStart=/bin/sh -c '${daemonSourceExecCommand(spec)}'`,
434
450
  "Restart=always",
435
451
  "RestartSec=5",
436
452
  "StandardOutput=journal",
@@ -451,11 +467,19 @@ function launchdPlist(spec, label) {
451
467
  '<plist version="1.0">',
452
468
  "<dict>",
453
469
  " <key>Label</key>",
454
- ` <string>${label}</string>`,
470
+ ` <string>${xmlEscape(label)}</string>`,
471
+ // §2a: same source-wrapper as the systemd unit so macOS actually loads the
472
+ // env file (the plist historically never did). The secret is never inlined
473
+ // here — it stays in the 0o600 sourced file.
455
474
  " <key>ProgramArguments</key>",
456
- ` <array><string>${spec.binPath}</string></array>`,
475
+ " <array>",
476
+ " <string>/bin/sh</string>",
477
+ " <string>-c</string>",
478
+ ` <string>${xmlEscape(daemonSourceExecCommand(spec))}</string>`,
479
+ " </array>",
480
+ // §2b: KOJEE_WEBHOOK_EXPECTED in the wrapper env (NOT the sourced file).
457
481
  " <key>EnvironmentVariables</key>",
458
- ` <dict><key>KOJEE_RUNTIME</key><string>${spec.runtime}</string></dict>`,
482
+ ` <dict><key>KOJEE_RUNTIME</key><string>${xmlEscape(spec.runtime)}</string><key>KOJEE_WEBHOOK_EXPECTED</key><string>1</string></dict>`,
459
483
  " <key>RunAtLoad</key><true/>",
460
484
  " <key>KeepAlive</key><true/>",
461
485
  "</dict>",
@@ -508,100 +532,17 @@ function writeService(platform, spec) {
508
532
  return plan;
509
533
  }
510
534
 
511
- // src/wizard/capabilities/hermes-mcp-config.ts
512
- import fs6 from "fs";
513
- import path6 from "path";
514
- import { parseDocument, isMap } from "yaml";
515
- var HERMES_MCP_SERVER_NAME = "kojee";
516
- var HERMES_INSTANCE_KEY = "hermes-gateway";
517
- var HERMES_MCP_CONNECT_TIMEOUT_S = 120;
518
- function defaultHermesConfigPath(homeDir) {
519
- return path6.join(homeDir, ".hermes", "config.yaml");
520
- }
521
- function buildKojeeEntry(binPath) {
522
- return {
523
- command: binPath,
524
- args: [],
525
- env: {
526
- KOJEE_RUNTIME: "hermes",
527
- KOJEE_INSTANCE: HERMES_INSTANCE_KEY,
528
- // Vanilla tools-only proxy: NEVER a second webhook delivery (see header).
529
- KOJEE_WEBHOOK_URL: ""
530
- },
531
- connect_timeout: HERMES_MCP_CONNECT_TIMEOUT_S
532
- };
533
- }
534
- function resolveMode(filePath) {
535
- try {
536
- return fs6.statSync(filePath).mode & 511;
537
- } catch {
538
- return 384;
539
- }
540
- }
541
- function atomicWrite(filePath, content, mode) {
542
- fs6.mkdirSync(path6.dirname(filePath), { recursive: true });
543
- const tmp = `${filePath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
544
- fs6.writeFileSync(tmp, content, { mode });
545
- fs6.renameSync(tmp, filePath);
546
- }
547
- function backupSuffix(now) {
548
- return (now ? now() : /* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
549
- }
550
- function writeHermesMcpConfig(configPath, opts) {
551
- let raw = null;
552
- try {
553
- raw = fs6.readFileSync(configPath, "utf8");
554
- } catch {
555
- raw = null;
556
- }
557
- let backedUp;
558
- let doc = parseDocument("");
559
- if (raw !== null && raw.trim() !== "") {
560
- const parsed = parseDocument(raw);
561
- if (parsed.errors.length > 0) {
562
- backedUp = `${configPath}.corrupt-${backupSuffix(opts.now)}`;
563
- fs6.copyFileSync(configPath, backedUp);
564
- } else {
565
- doc = parsed;
566
- }
567
- }
568
- if (doc.hasIn(["mcp_servers"]) && !isMap(doc.getIn(["mcp_servers"], true))) {
569
- doc.deleteIn(["mcp_servers"]);
570
- }
571
- doc.setIn(["mcp_servers", HERMES_MCP_SERVER_NAME], buildKojeeEntry(opts.binPath));
572
- atomicWrite(configPath, String(doc), resolveMode(configPath));
573
- return { configPath, ...backedUp ? { backedUp } : {} };
574
- }
575
- function removeHermesMcpServer(configPath) {
576
- let raw;
577
- try {
578
- raw = fs6.readFileSync(configPath, "utf8");
579
- } catch {
580
- return false;
581
- }
582
- const doc = parseDocument(raw);
583
- if (doc.errors.length > 0) return false;
584
- if (!doc.hasIn(["mcp_servers", HERMES_MCP_SERVER_NAME])) return false;
585
- doc.deleteIn(["mcp_servers", HERMES_MCP_SERVER_NAME]);
586
- const servers = doc.getIn(["mcp_servers"], true);
587
- if (isMap(servers) && servers.items.length === 0) {
588
- doc.deleteIn(["mcp_servers"]);
589
- }
590
- atomicWrite(configPath, String(doc), resolveMode(configPath));
591
- return true;
592
- }
593
-
594
535
  // src/wizard/installers/hermes.ts
595
536
  function installHermes(inp) {
596
- const daemonEnv = path7.join(inp.homeDir, ".kojee", "hermes.env");
597
- const adapterEnv = path7.join(inp.homeDir, ".hermes", ".env");
598
- const pluginDir = path7.join(inp.homeDir, ".hermes", "plugins", "kojee-tandem");
537
+ const daemonEnv = path6.join(inp.homeDir, ".kojee", "hermes.env");
538
+ const adapterEnv = path6.join(inp.homeDir, ".hermes", ".env");
539
+ const pluginDir = path6.join(inp.homeDir, ".hermes", "plugins", "kojee-tandem");
599
540
  if (!inp.skipPayload) {
600
541
  const missing = missingPayloadFiles("hermes", inp.payloadBaseDir, HERMES_PAYLOAD_FILES);
601
542
  if (missing.length > 0) {
602
543
  return {
603
544
  runtime: "hermes",
604
- output: `hermes install ERROR: the bundled plugin payload is incomplete \u2014 missing ${missing.join(", ")} under ${path7.join(inp.payloadBaseDir, "plugins", "hermes")}. No changes were written. Run \`npm run build\` to stage dist/plugins/hermes, then retry.`,
545
+ output: `hermes install ERROR: the bundled plugin payload is incomplete \u2014 missing ${missing.join(", ")} under ${path6.join(inp.payloadBaseDir, "plugins", "hermes")}. No changes were written. Run \`npm run build\` to stage dist/plugins/hermes, then retry.`,
605
546
  exitCode: 2,
606
547
  secret: "",
607
548
  secretReused: false
@@ -1016,7 +957,7 @@ function buildDaemonEnvBlock(runtime, wh, envFile) {
1016
957
  lines.push(` export KOJEE_WEBHOOK_SECRET=<generated; in ${envFile}>`);
1017
958
  for (const [k, v] of wh.signatureEnv) lines.push(` export ${k}=${shellSingleQuote(v)}`);
1018
959
  lines.push(` (validated: ${wh.redactedSummary})`);
1019
- lines.push(` source ${envFile}`);
960
+ lines.push(` set -a; . ${envFile}; set +a`);
1020
961
  } else {
1021
962
  lines.push("Set the daemon env (no receiver URL supplied yet):");
1022
963
  lines.push(` export KOJEE_RUNTIME=${shellSingleQuote(runtime)}`);
@@ -1030,7 +971,7 @@ function buildDaemonEnvBlock(runtime, wh, envFile) {
1030
971
  return lines;
1031
972
  }
1032
973
  function distDir() {
1033
- return path8.dirname(fileURLToPath2(import.meta.url));
974
+ return path7.dirname(fileURLToPath2(import.meta.url));
1034
975
  }
1035
976
  function resolveBinPath() {
1036
977
  const entry = process.argv[1];
@@ -1094,7 +1035,7 @@ function configureHermes(opts) {
1094
1035
  return { runtime, output: lines.join("\n"), exitCode: install.exitCode };
1095
1036
  }
1096
1037
  recordRuntime(runtime);
1097
- const envFile = path8.join(home, ".kojee", "hermes.env");
1038
+ const envFile = path7.join(home, ".kojee", "hermes.env");
1098
1039
  lines.push(...buildDaemonEnvBlock(runtime, wh, envFile));
1099
1040
  lines.push("");
1100
1041
  lines.push(install.output);
@@ -1116,7 +1057,7 @@ function configureHermes(opts) {
1116
1057
  return { runtime, output: lines.join("\n"), exitCode: 0 };
1117
1058
  }
1118
1059
  function openclawConfigPath(opts) {
1119
- return opts.openclawConfigPath ?? path8.join(kojeeHomeDir(), ".openclaw", "openclaw.json");
1060
+ return opts.openclawConfigPath ?? path7.join(kojeeHomeDir(), ".openclaw", "openclaw.json");
1120
1061
  }
1121
1062
  function configureOpenclaw(opts) {
1122
1063
  const runtime = "openclaw";
@@ -1164,9 +1105,9 @@ async function runWizardUninstall(runtime, opts) {
1164
1105
  removedMcp ? ` removed mcp_servers.kojee from ${hermesConfig} (operator config preserved)` : ` no mcp_servers.kojee in ${hermesConfig} \u2014 nothing to remove`
1165
1106
  );
1166
1107
  lines.push(" (stop the daemon service + reload the gateway to disable the webhook wake path.)");
1167
- const envPath = path8.join(kojeeHomeDir(), ".kojee", `${effective}.env`);
1108
+ const envPath = path7.join(kojeeHomeDir(), ".kojee", `${effective}.env`);
1168
1109
  try {
1169
- fs7.unlinkSync(envPath);
1110
+ fs6.unlinkSync(envPath);
1170
1111
  lines.push(` removed ${envPath}`);
1171
1112
  } catch {
1172
1113
  }
@@ -331,6 +331,11 @@ async function startProxy(config) {
331
331
  }
332
332
  for (const step of started.teardown) teardownSteps.push(step);
333
333
  } else {
334
+ if (process.env["KOJEE_WEBHOOK_EXPECTED"]) {
335
+ console.error(
336
+ "[kojee-mcp] webhook expected (KOJEE_WEBHOOK_EXPECTED=1) but no delivery configured \u2014 env injection likely failed; check the daemon env file (~/.kojee/hermes.env) / service wiring (ExecStart should be `/bin/sh -c 'set -a; . <envFile>; set +a; exec <bin>'`)."
337
+ );
338
+ }
334
339
  server = createMcpServer(registry, adapter, tandemMembershipCount);
335
340
  }
336
341
  process.stdin.on("end", () => shutdown("stdin end"));
@@ -0,0 +1,90 @@
1
+ // src/wizard/capabilities/hermes-mcp-config.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { parseDocument, isMap } from "yaml";
5
+ var HERMES_MCP_SERVER_NAME = "kojee";
6
+ var HERMES_INSTANCE_KEY = "hermes-gateway";
7
+ var HERMES_MCP_CONNECT_TIMEOUT_S = 120;
8
+ function defaultHermesConfigPath(homeDir) {
9
+ return path.join(homeDir, ".hermes", "config.yaml");
10
+ }
11
+ function buildKojeeEntry(binPath) {
12
+ return {
13
+ command: binPath,
14
+ args: [],
15
+ env: {
16
+ KOJEE_RUNTIME: "hermes",
17
+ KOJEE_INSTANCE: HERMES_INSTANCE_KEY,
18
+ // Vanilla tools-only proxy: NEVER a second webhook delivery (see header).
19
+ KOJEE_WEBHOOK_URL: ""
20
+ },
21
+ connect_timeout: HERMES_MCP_CONNECT_TIMEOUT_S
22
+ };
23
+ }
24
+ function resolveMode(filePath) {
25
+ try {
26
+ return fs.statSync(filePath).mode & 511;
27
+ } catch {
28
+ return 384;
29
+ }
30
+ }
31
+ function atomicWrite(filePath, content, mode) {
32
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
33
+ const tmp = `${filePath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
34
+ fs.writeFileSync(tmp, content, { mode });
35
+ fs.renameSync(tmp, filePath);
36
+ }
37
+ function backupSuffix(now) {
38
+ return (now ? now() : /* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
39
+ }
40
+ function writeHermesMcpConfig(configPath, opts) {
41
+ let raw = null;
42
+ try {
43
+ raw = fs.readFileSync(configPath, "utf8");
44
+ } catch {
45
+ raw = null;
46
+ }
47
+ let backedUp;
48
+ let doc = parseDocument("");
49
+ if (raw !== null && raw.trim() !== "") {
50
+ const parsed = parseDocument(raw);
51
+ if (parsed.errors.length > 0) {
52
+ backedUp = `${configPath}.corrupt-${backupSuffix(opts.now)}`;
53
+ fs.copyFileSync(configPath, backedUp);
54
+ } else {
55
+ doc = parsed;
56
+ }
57
+ }
58
+ if (doc.hasIn(["mcp_servers"]) && !isMap(doc.getIn(["mcp_servers"], true))) {
59
+ doc.deleteIn(["mcp_servers"]);
60
+ }
61
+ doc.setIn(["mcp_servers", HERMES_MCP_SERVER_NAME], buildKojeeEntry(opts.binPath));
62
+ atomicWrite(configPath, String(doc), resolveMode(configPath));
63
+ return { configPath, ...backedUp ? { backedUp } : {} };
64
+ }
65
+ function removeHermesMcpServer(configPath) {
66
+ let raw;
67
+ try {
68
+ raw = fs.readFileSync(configPath, "utf8");
69
+ } catch {
70
+ return false;
71
+ }
72
+ const doc = parseDocument(raw);
73
+ if (doc.errors.length > 0) return false;
74
+ if (!doc.hasIn(["mcp_servers", HERMES_MCP_SERVER_NAME])) return false;
75
+ doc.deleteIn(["mcp_servers", HERMES_MCP_SERVER_NAME]);
76
+ const servers = doc.getIn(["mcp_servers"], true);
77
+ if (isMap(servers) && servers.items.length === 0) {
78
+ doc.deleteIn(["mcp_servers"]);
79
+ }
80
+ atomicWrite(configPath, String(doc), resolveMode(configPath));
81
+ return true;
82
+ }
83
+
84
+ export {
85
+ HERMES_MCP_SERVER_NAME,
86
+ HERMES_INSTANCE_KEY,
87
+ defaultHermesConfigPath,
88
+ writeHermesMcpConfig,
89
+ removeHermesMcpServer
90
+ };
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-EIAUW6KO.js";
5
5
  import {
6
6
  startProxy
7
- } from "./chunk-O2GELC3V.js";
7
+ } from "./chunk-526UIEAL.js";
8
8
  import "./chunk-TCWIXG5C.js";
9
9
  import {
10
10
  pairedConfigPath
@@ -48,7 +48,7 @@ program.command("pair <code>").description("Pair this machine against Kojee usin
48
48
  program.command("connect <code>").description(
49
49
  "Connect this runtime to Kojee with a per-agent pair code from the dashboard (claude-code | codex | openclaw | hermes). claude-code/codex/openclaw write a per-runtime paired slot (~/.kojee/agents/<runtime>/config.json) and point the runtime launcher at it via --paired-config; hermes writes the global ~/.kojee/config.json (its daemon + `kojee-mcp send` read that). Idempotent."
50
50
  ).requiredOption("--runtime <id>", "Target runtime: claude-code | codex | openclaw | hermes").option("--url <url>", "Broker base URL (default: the canonical staging broker)").action(async (code, opts) => {
51
- const { runConnect } = await import("./connect-handler-D3VQMUIS.js");
51
+ const { runConnect } = await import("./connect-handler-YYBLFRDP.js");
52
52
  const result = await runConnect({
53
53
  code,
54
54
  runtime: opts.runtime,
@@ -124,7 +124,7 @@ program.command("tail <path>").description("Stream a file's contents and follow
124
124
  }
125
125
  });
126
126
  program.command("doctor").description("Diagnose the kojee wake path (proxy, hook-server, SSE stream, event log, Monitor) and print the exact wake recipe").action(async () => {
127
- const { runDoctor } = await import("./doctor-ZHV362IF.js");
127
+ const { runDoctor } = await import("./doctor-NHUPLM6O.js");
128
128
  const code = await runDoctor();
129
129
  process.exit(code);
130
130
  });
@@ -143,7 +143,7 @@ function addInstallOptions(cmd, runtimeHelp) {
143
143
  function makeInstallAction(verb) {
144
144
  return async (opts) => {
145
145
  const interactive = process.stdin.isTTY === true && opts.runtime === void 0;
146
- const { runSetup, resolvePairCode } = await import("./setup-handler-5Q47JYPQ.js");
146
+ const { runSetup, resolvePairCode } = await import("./setup-handler-6BDICX5F.js");
147
147
  const pairCode = resolvePairCode(opts);
148
148
  const result = await runSetup({
149
149
  verb,
@@ -2,9 +2,10 @@ import {
2
2
  DEFAULT_BROKER_URL,
3
3
  reconcileConnectPairSlot,
4
4
  runWizard
5
- } from "./chunk-QNGMMBOD.js";
5
+ } from "./chunk-4E6WMRTM.js";
6
6
  import "./chunk-6XWTUDWW.js";
7
7
  import "./chunk-E6WMFMM2.js";
8
+ import "./chunk-GEGUWQYT.js";
8
9
  import "./chunk-77HWBSRH.js";
9
10
  import "./chunk-TMCNB4JH.js";
10
11
  import "./chunk-D6JKFJ6A.js";
@@ -365,6 +365,12 @@ async function runDoctor() {
365
365
  console.error(formatOpenclawDoctorReport(report2));
366
366
  return report2.verdict === "broken" ? 1 : 0;
367
367
  }
368
+ const { hermesArtifactsPresent, collectHermesDoctorReport, formatHermesDoctorReport } = await import("./doctor-hermes-7N7O422Y.js");
369
+ if (hermesArtifactsPresent()) {
370
+ const hermesReport = collectHermesDoctorReport();
371
+ console.error(formatHermesDoctorReport(hermesReport));
372
+ return hermesReport.verdict === "broken" ? 1 : 0;
373
+ }
368
374
  const report = await collectDoctorReport();
369
375
  console.error(formatDoctorReport(report));
370
376
  return report.verdict === "broken" ? 1 : 0;
@@ -0,0 +1,449 @@
1
+ import {
2
+ HERMES_INSTANCE_KEY,
3
+ HERMES_MCP_SERVER_NAME
4
+ } from "./chunk-GEGUWQYT.js";
5
+
6
+ // src/doctor-hermes.ts
7
+ import fs from "fs";
8
+ import os from "os";
9
+ import path from "path";
10
+ import crypto from "crypto";
11
+ import { execFileSync } from "child_process";
12
+ import { parse } from "yaml";
13
+
14
+ // src/reconcile/index.ts
15
+ var FindingCollector = class {
16
+ items = [];
17
+ /** Append a pre-built Finding. */
18
+ add(finding) {
19
+ this.items.push(finding);
20
+ }
21
+ /** Append a Finding from its parts (the common call site). */
22
+ push(id, severity, detail) {
23
+ this.items.push({ id, severity, detail });
24
+ }
25
+ /** The findings in insertion order (read-only view). */
26
+ get findings() {
27
+ return this.items;
28
+ }
29
+ /**
30
+ * Roll the findings up into the doctor verdict: any `broken` ⇒ broken; else any
31
+ * `warn` ⇒ degraded; else healthy. `unknown` never fails the verdict (it is an
32
+ * "I couldn't determine this" signal, same as the doctor's `?` marks).
33
+ */
34
+ verdict() {
35
+ if (this.items.some((f) => f.severity === "broken")) return "broken";
36
+ if (this.items.some((f) => f.severity === "warn")) return "degraded";
37
+ return "healthy";
38
+ }
39
+ };
40
+
41
+ // src/doctor-hermes.ts
42
+ var WIZARD_RERUN = "re-run `kojee-mcp init --runtime hermes`";
43
+ var DEFAULT_LISTEN_HOST = "127.0.0.1";
44
+ var DEFAULT_LISTEN_PORT = "8645";
45
+ var DEFAULT_LISTEN_PATH = "/kojee-tandem";
46
+ function serviceUnitPath(homeDir, platform) {
47
+ if (platform === "darwin") {
48
+ return path.join(homeDir, "Library", "LaunchAgents", "net.kojee.kojee-hermes.plist");
49
+ }
50
+ return path.join(homeDir, ".config", "systemd", "user", "kojee-hermes.service");
51
+ }
52
+ function hermesEnvPathFor(homeDir) {
53
+ return path.join(homeDir, ".kojee", "hermes.env");
54
+ }
55
+ function hermesArtifactsPresent(deps = {}) {
56
+ const homeDir = deps.homeDir ?? os.homedir();
57
+ const platform = deps.platform ?? process.platform;
58
+ const exists = deps.exists ?? ((p) => fs.existsSync(p));
59
+ return exists(hermesEnvPathFor(homeDir)) || exists(serviceUnitPath(homeDir, platform));
60
+ }
61
+ function unquoteEnvValue(raw) {
62
+ let v = raw.trim();
63
+ if (v.length >= 2 && (v.startsWith("'") && v.endsWith("'") || v.startsWith('"') && v.endsWith('"'))) {
64
+ v = v.slice(1, -1);
65
+ }
66
+ return v.replace(/'\\''/g, "'");
67
+ }
68
+ function parseEnvFile(text) {
69
+ const vars = /* @__PURE__ */ new Map();
70
+ const exportKeys = [];
71
+ for (const rawLine of text.split("\n")) {
72
+ const line = rawLine.trim();
73
+ if (line === "" || line.startsWith("#")) continue;
74
+ const m = line.match(/^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
75
+ if (!m) continue;
76
+ const key = m[2];
77
+ if (m[1] !== void 0) exportKeys.push(key);
78
+ vars.set(key, unquoteEnvValue(m[3]));
79
+ }
80
+ return { vars, exportKeys };
81
+ }
82
+ function readChildEnv(configText) {
83
+ if (configText === null) return null;
84
+ let doc;
85
+ try {
86
+ doc = parse(configText);
87
+ } catch {
88
+ return null;
89
+ }
90
+ if (!doc || typeof doc !== "object") return null;
91
+ const servers = doc["mcp_servers"];
92
+ if (!servers || typeof servers !== "object") return null;
93
+ const kojee = servers[HERMES_MCP_SERVER_NAME];
94
+ if (!kojee || typeof kojee !== "object") return null;
95
+ const env = kojee["env"];
96
+ if (!env || typeof env !== "object") return null;
97
+ const out = {};
98
+ for (const [k, v] of Object.entries(env)) {
99
+ if (typeof v === "string") out[k] = v;
100
+ }
101
+ return out;
102
+ }
103
+ function sha256(value) {
104
+ return crypto.createHash("sha256").update(value, "utf8").digest("hex");
105
+ }
106
+ function defaultReadText(filePath) {
107
+ try {
108
+ return fs.readFileSync(filePath, "utf8");
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+ function defaultProbeService(platform) {
114
+ return () => {
115
+ try {
116
+ if (platform === "darwin") {
117
+ const out2 = execFileSync("launchctl", ["list"], { encoding: "utf8" });
118
+ return { active: out2.includes("net.kojee.kojee-hermes") };
119
+ }
120
+ const out = execFileSync("systemctl", ["--user", "is-active", "kojee-hermes"], {
121
+ encoding: "utf8"
122
+ }).trim();
123
+ return { active: out === "active" };
124
+ } catch (err) {
125
+ const stdout = err.stdout;
126
+ if (stdout !== void 0) {
127
+ const s = (typeof stdout === "string" ? stdout : stdout.toString()).trim();
128
+ if (s.length > 0) return { active: s === "active" };
129
+ }
130
+ return null;
131
+ }
132
+ };
133
+ }
134
+ function defaultProbeReceiver(platform) {
135
+ return (_host, port) => {
136
+ try {
137
+ if (platform === "darwin") {
138
+ try {
139
+ execFileSync("lsof", [`-iTCP:${port}`, "-sTCP:LISTEN", "-n", "-P"], { encoding: "utf8" });
140
+ return true;
141
+ } catch (err) {
142
+ if (err.status === 1) return false;
143
+ return null;
144
+ }
145
+ }
146
+ const out = execFileSync("ss", ["-ltnH"], { encoding: "utf8" });
147
+ const re = new RegExp(`:${port}(\\s|$)`);
148
+ return out.split("\n").some((l) => re.test(l));
149
+ } catch {
150
+ return null;
151
+ }
152
+ };
153
+ }
154
+ function defaultReadDaemonEnv(platform) {
155
+ return () => {
156
+ if (platform !== "linux") return null;
157
+ try {
158
+ const pidStr = execFileSync(
159
+ "systemctl",
160
+ ["--user", "show", "-p", "MainPID", "--value", "kojee-hermes"],
161
+ { encoding: "utf8" }
162
+ ).trim();
163
+ const pid = Number.parseInt(pidStr, 10);
164
+ if (!Number.isFinite(pid) || pid <= 0) return null;
165
+ const raw = fs.readFileSync(`/proc/${pid}/environ`, "utf8");
166
+ const out = {};
167
+ for (const pair of raw.split("\0")) {
168
+ const eq = pair.indexOf("=");
169
+ if (eq <= 0) continue;
170
+ out[pair.slice(0, eq)] = pair.slice(eq + 1);
171
+ }
172
+ return out;
173
+ } catch {
174
+ return null;
175
+ }
176
+ };
177
+ }
178
+ function okFromSeverity(sev) {
179
+ return sev === "ok" ? true : sev === "warn" ? "warn" : sev === "broken" ? false : "unknown";
180
+ }
181
+ function emit(checks, collector, id, severity, detail) {
182
+ checks.push({ name: id, ok: okFromSeverity(severity), detail });
183
+ collector.push(id, severity, detail);
184
+ }
185
+ function collectHermesDoctorReport(deps = {}) {
186
+ const homeDir = deps.homeDir ?? os.homedir();
187
+ const platform = deps.platform ?? process.platform;
188
+ const readText = deps.readText ?? defaultReadText;
189
+ const probeService = deps.probeService ?? defaultProbeService(platform);
190
+ const probeReceiver = deps.probeReceiver ?? defaultProbeReceiver(platform);
191
+ const readDaemonEnv = deps.readDaemonEnv ?? defaultReadDaemonEnv(platform);
192
+ const hermesEnvPath = hermesEnvPathFor(homeDir);
193
+ const adapterEnvPath = path.join(homeDir, ".hermes", ".env");
194
+ const configYamlPath = path.join(homeDir, ".hermes", "config.yaml");
195
+ const unitPath = serviceUnitPath(homeDir, platform);
196
+ const daemonEnvText = readText(hermesEnvPath);
197
+ const adapterEnvText = readText(adapterEnvPath);
198
+ const unitText = readText(unitPath);
199
+ const configText = readText(configYamlPath);
200
+ const daemonEnv = daemonEnvText !== null ? parseEnvFile(daemonEnvText) : null;
201
+ const adapterEnv = adapterEnvText !== null ? parseEnvFile(adapterEnvText) : null;
202
+ const childEnv = readChildEnv(configText);
203
+ const checks = [];
204
+ const collector = new FindingCollector();
205
+ if (daemonEnv === null) {
206
+ emit(checks, collector, "webhook.env-format", "unknown", `hermes.env unreadable at ${hermesEnvPath}`);
207
+ } else if (daemonEnv.exportKeys.length > 0) {
208
+ const unitUsesEnvFile = unitText !== null && /^\s*EnvironmentFile=/m.test(unitText);
209
+ const keys = daemonEnv.exportKeys.join(", ");
210
+ if (unitUsesEnvFile) {
211
+ emit(
212
+ checks,
213
+ collector,
214
+ "webhook.env-format",
215
+ "broken",
216
+ `hermes.env has export-prefixed line(s) [${keys}] AND the service unit uses EnvironmentFile= \u2014 systemd DROPS them, so the daemon boots with those vars UNSET (silent webhook-off + seat churn). ${WIZARD_RERUN} to rewrite the unit (source wrapper) + env (bare).`
217
+ );
218
+ } else {
219
+ emit(
220
+ checks,
221
+ collector,
222
+ "webhook.env-format",
223
+ "warn",
224
+ `hermes.env has legacy export-prefixed line(s) [${keys}] \u2014 harmless under the source-wrapper unit (set -a exports them) but ${WIZARD_RERUN} to normalize to bare form.`
225
+ );
226
+ }
227
+ } else {
228
+ emit(
229
+ checks,
230
+ collector,
231
+ "webhook.env-format",
232
+ "ok",
233
+ "hermes.env is bare single-quoted (no export-prefixed lines) \u2014 sources cleanly."
234
+ );
235
+ }
236
+ const daemonUrl = (daemonEnv?.vars.get("KOJEE_WEBHOOK_URL") ?? "").trim();
237
+ const listenHost = adapterEnv?.vars.get("KOJEE_TANDEM_LISTEN_HOST") ?? DEFAULT_LISTEN_HOST;
238
+ const listenPort = adapterEnv?.vars.get("KOJEE_TANDEM_LISTEN_PORT") ?? DEFAULT_LISTEN_PORT;
239
+ const listenPath = adapterEnv?.vars.get("KOJEE_TANDEM_LISTEN_PATH") ?? DEFAULT_LISTEN_PATH;
240
+ if (!daemonUrl) {
241
+ emit(
242
+ checks,
243
+ collector,
244
+ "webhook.url-receiver-align",
245
+ "broken",
246
+ `daemon KOJEE_WEBHOOK_URL is EMPTY \u2014 no event stream (null delivery); the daemon will not POST wakes. Set the receiver URL: ${WIZARD_RERUN}.`
247
+ );
248
+ } else {
249
+ let parsed = null;
250
+ try {
251
+ parsed = new URL(daemonUrl);
252
+ } catch {
253
+ parsed = null;
254
+ }
255
+ if (parsed === null) {
256
+ emit(
257
+ checks,
258
+ collector,
259
+ "webhook.url-receiver-align",
260
+ "broken",
261
+ `daemon KOJEE_WEBHOOK_URL is not a valid URL \u2014 ${WIZARD_RERUN}.`
262
+ );
263
+ } else {
264
+ const urlPort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
265
+ const aligned = parsed.hostname === listenHost && urlPort === listenPort && parsed.pathname === listenPath;
266
+ emit(
267
+ checks,
268
+ collector,
269
+ "webhook.url-receiver-align",
270
+ aligned ? "ok" : "broken",
271
+ aligned ? `daemon URL host/port/path aligns with the receiver bind (${listenHost}:${listenPort}${listenPath}).` : `daemon URL (${parsed.hostname}:${urlPort}${parsed.pathname}) != receiver bind (${listenHost}:${listenPort}${listenPath}) \u2014 POSTs miss the listener. Align them.`
272
+ );
273
+ }
274
+ }
275
+ const daemonSecret = daemonEnv?.vars.get("KOJEE_WEBHOOK_SECRET");
276
+ const adapterSecret = adapterEnv?.vars.get("KOJEE_WEBHOOK_SECRET");
277
+ if (!daemonSecret || !adapterSecret) {
278
+ const which = [!daemonSecret ? "daemon (hermes.env)" : null, !adapterSecret ? "adapter (~/.hermes/.env)" : null].filter((s) => s !== null).join(" and ");
279
+ emit(
280
+ checks,
281
+ collector,
282
+ "webhook.secret-match",
283
+ "broken",
284
+ `KOJEE_WEBHOOK_SECRET missing on ${which} \u2014 the receiver can't verify signed POSTs (401). ${WIZARD_RERUN}.`
285
+ );
286
+ } else {
287
+ const same = sha256(daemonSecret) === sha256(adapterSecret);
288
+ emit(
289
+ checks,
290
+ collector,
291
+ "webhook.secret-match",
292
+ same ? "ok" : "broken",
293
+ same ? "daemon + adapter secrets match (compared by sha256; values never printed)." : `daemon vs adapter KOJEE_WEBHOOK_SECRET DIFFER (compared by sha256; values never printed) \u2014 every POST is rejected 401. Resync one side: ${WIZARD_RERUN} (writes one secret both sides).`
294
+ );
295
+ }
296
+ const svc = probeService();
297
+ if (svc === null) {
298
+ emit(
299
+ checks,
300
+ collector,
301
+ "webhook.pusher-up",
302
+ "unknown",
303
+ "could not determine kojee-hermes service state (systemctl/launchctl unavailable)."
304
+ );
305
+ } else if (svc.active) {
306
+ emit(checks, collector, "webhook.pusher-up", "ok", "kojee-hermes service is active.");
307
+ } else {
308
+ emit(
309
+ checks,
310
+ collector,
311
+ "webhook.pusher-up",
312
+ "broken",
313
+ "kojee-hermes service is NOT active \u2014 no wakes are being pushed. Enable+start it (`systemctl --user enable --now kojee-hermes`) \u2014 shared infra, so the principal's call."
314
+ );
315
+ }
316
+ const receiver = probeReceiver(listenHost, Number.parseInt(listenPort, 10));
317
+ if (receiver === null) {
318
+ emit(
319
+ checks,
320
+ collector,
321
+ "webhook.receiver-up",
322
+ "unknown",
323
+ `could not probe the receiver bind ${listenHost}:${listenPort} (ss/lsof unavailable).`
324
+ );
325
+ } else if (receiver) {
326
+ emit(
327
+ checks,
328
+ collector,
329
+ "webhook.receiver-up",
330
+ "ok",
331
+ `receiver is listening on ${listenHost}:${listenPort}.`
332
+ );
333
+ } else {
334
+ emit(
335
+ checks,
336
+ collector,
337
+ "webhook.receiver-up",
338
+ "warn",
339
+ `nothing is listening on ${listenHost}:${listenPort} \u2014 the Hermes gateway (kojee-tandem plugin) receiver may be down; restart the gateway to load the plugin.`
340
+ );
341
+ }
342
+ if (childEnv === null) {
343
+ emit(
344
+ checks,
345
+ collector,
346
+ "webhook.no-duplicate",
347
+ "unknown",
348
+ `could not read mcp_servers.${HERMES_MCP_SERVER_NAME}.env from ~/.hermes/config.yaml.`
349
+ );
350
+ } else {
351
+ const childUrl = (childEnv["KOJEE_WEBHOOK_URL"] ?? "").trim();
352
+ emit(
353
+ checks,
354
+ collector,
355
+ "webhook.no-duplicate",
356
+ childUrl === "" ? "ok" : "broken",
357
+ childUrl === "" ? "mcp child KOJEE_WEBHOOK_URL is empty (hard-off) \u2014 no duplicate delivery." : `mcp_servers.${HERMES_MCP_SERVER_NAME} has a NON-EMPTY KOJEE_WEBHOOK_URL \u2014 the tools-only child would DOUBLE-DELIVER wakes for the seat. Clear it (keep the empty hard-off).`
358
+ );
359
+ }
360
+ const daemonInstance = (daemonEnv?.vars.get("KOJEE_INSTANCE") ?? "").trim();
361
+ const childInstance = (childEnv?.["KOJEE_INSTANCE"] ?? "").trim();
362
+ if (!daemonInstance) {
363
+ emit(
364
+ checks,
365
+ collector,
366
+ "webhook.instance-align",
367
+ "broken",
368
+ `daemon KOJEE_INSTANCE is unset \u2014 daemon + MCP child may resolve to DIFFERENT sessions (split-brain seat). Expected '${HERMES_INSTANCE_KEY}'. ${WIZARD_RERUN}.`
369
+ );
370
+ } else if (childInstance && daemonInstance !== childInstance) {
371
+ emit(
372
+ checks,
373
+ collector,
374
+ "webhook.instance-align",
375
+ "broken",
376
+ `daemon KOJEE_INSTANCE='${daemonInstance}' != mcp child KOJEE_INSTANCE='${childInstance}' \u2014 split-brain: a tandem_send from the agent lands on a different seat than the daemon streams wakes for. ${WIZARD_RERUN}.`
377
+ );
378
+ } else if (daemonInstance !== HERMES_INSTANCE_KEY) {
379
+ emit(
380
+ checks,
381
+ collector,
382
+ "webhook.instance-align",
383
+ "warn",
384
+ `daemon KOJEE_INSTANCE='${daemonInstance}' != expected '${HERMES_INSTANCE_KEY}' (non-standard but self-consistent with the child).`
385
+ );
386
+ } else {
387
+ emit(
388
+ checks,
389
+ collector,
390
+ "webhook.instance-align",
391
+ "ok",
392
+ `daemon + mcp child share KOJEE_INSTANCE='${HERMES_INSTANCE_KEY}' \u2014 one backend seat.`
393
+ );
394
+ }
395
+ if (platform !== "linux") {
396
+ emit(
397
+ checks,
398
+ collector,
399
+ "webhook.env-reaches-daemon",
400
+ "unknown",
401
+ "n/a \u2014 reads /proc/<pid>/environ (Linux only)."
402
+ );
403
+ } else {
404
+ const liveEnv = readDaemonEnv();
405
+ if (liveEnv === null) {
406
+ emit(
407
+ checks,
408
+ collector,
409
+ "webhook.env-reaches-daemon",
410
+ "unknown",
411
+ "daemon not running (no MainPID) or its /proc environ is unreadable."
412
+ );
413
+ } else {
414
+ const missing = ["KOJEE_WEBHOOK_URL", "KOJEE_WEBHOOK_SECRET", "KOJEE_INSTANCE"].filter(
415
+ (k) => !((liveEnv[k] ?? "").length > 0)
416
+ );
417
+ emit(
418
+ checks,
419
+ collector,
420
+ "webhook.env-reaches-daemon",
421
+ missing.length === 0 ? "ok" : "broken",
422
+ missing.length === 0 ? "live daemon env carries KOJEE_WEBHOOK_URL/SECRET/INSTANCE (injection reached the process; secret value never read)." : `live daemon env is MISSING ${missing.join(", ")} \u2014 env injection did NOT reach the running daemon (source wrapper not applied / stale unit). ${WIZARD_RERUN}, then restart the service.`
423
+ );
424
+ }
425
+ }
426
+ return { checks, findings: collector.findings, verdict: collector.verdict() };
427
+ }
428
+ function formatHermesDoctorReport(report) {
429
+ const mark = (ok) => ok === true ? "\u2713" : ok === "warn" ? "\u26A0" : ok === "unknown" ? "?" : "\u2717";
430
+ const lines = [];
431
+ lines.push(`kojee-mcp doctor (hermes) \u2014 verdict: ${report.verdict.toUpperCase()}`);
432
+ lines.push("");
433
+ lines.push(" Wake mode: kojee-hermes daemon \u2192 HMAC-signed webhook \u2192 loopback receiver plugin.");
434
+ lines.push(" (Daemon streams the SSE + POSTs wakes; the tools-only MCP child never delivers.)");
435
+ lines.push("");
436
+ for (const c of report.checks) {
437
+ lines.push(` ${mark(c.ok)} ${c.name}: ${c.detail}`);
438
+ }
439
+ lines.push("");
440
+ lines.push(
441
+ "NOTE: read-only. Enabling/bouncing the kojee-hermes service (or the gateway) is an owner step \u2014 shared infra needs the principal's word."
442
+ );
443
+ return lines.join("\n");
444
+ }
445
+ export {
446
+ collectHermesDoctorReport,
447
+ formatHermesDoctorReport,
448
+ hermesArtifactsPresent
449
+ };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  listTandemIds,
3
3
  startProxy
4
- } from "./chunk-O2GELC3V.js";
4
+ } from "./chunk-526UIEAL.js";
5
5
  import "./chunk-TCWIXG5C.js";
6
6
  import "./chunk-TBVJOXIR.js";
7
7
  import "./chunk-Z5LPNJQ6.js";
@@ -3,9 +3,10 @@ import {
3
3
  pairSlotFor,
4
4
  reconcileConnectPairSlot,
5
5
  runWizard
6
- } from "./chunk-QNGMMBOD.js";
6
+ } from "./chunk-4E6WMRTM.js";
7
7
  import "./chunk-6XWTUDWW.js";
8
8
  import "./chunk-E6WMFMM2.js";
9
+ import "./chunk-GEGUWQYT.js";
9
10
  import {
10
11
  isWizardRuntime
11
12
  } from "./chunk-77HWBSRH.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kojee-mcp",
3
- "version": "0.7.5-staging.4",
3
+ "version": "0.7.5-staging.5",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {